Compare commits
37 Commits
568cb0e0bc
...
ui-config
| Author | SHA1 | Date | |
|---|---|---|---|
| c6341aa12f | |||
| 7cc724a133 | |||
| 8ff09eeeaf | |||
| 914a9cf243 | |||
| d084e806a1 | |||
| bb15b2bf15 | |||
| 4bec095a40 | |||
| 81e715ddb3 | |||
| c6d0fdd5d1 | |||
| 3a29f98c96 | |||
| 71007bd9a1 | |||
| 65a9b39966 | |||
| 58087a4b37 | |||
| 838aec562f | |||
| ec650fb45d | |||
| bf63510980 | |||
| b3519c94cd | |||
| 5cfb5b9d15 | |||
| 4545bde8ec | |||
| 14bf43487e | |||
| ed437cdb0f | |||
| c7db2c3ed8 | |||
| 5e3f10631f | |||
| 578b03d808 | |||
| 25fcd5d926 | |||
| c51ddc34c7 | |||
| b54707823c | |||
| 7fc80a49d0 | |||
| 7b42058d77 | |||
| ea5799ffb5 | |||
| f38d7e01a1 | |||
| ee5a5b6800 | |||
| 6913bbae48 | |||
| c08aad3f68 | |||
| 1236d00867 | |||
| e1cbd49728 | |||
| 6383965bfa |
@@ -0,0 +1,8 @@
|
|||||||
|
name: Local Config
|
||||||
|
version: 1.0.0
|
||||||
|
schema: v1
|
||||||
|
models:
|
||||||
|
- name: Autodetect
|
||||||
|
provider: lmstudio
|
||||||
|
model: AUTODETECT
|
||||||
|
apiBase: http://192.168.1.12:1234/v1/
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://app.kilo.ai/config.json"
|
||||||
|
}
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"continue.enableConsole": true
|
||||||
|
}
|
||||||
+374
-34
@@ -4,7 +4,125 @@ Tài liệu này mô tả kiến trúc tổng thể, mô hình dữ liệu và c
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Kiến Trúc Tổng Quan (System Overview)
|
## 8. Cấu Trúc Thư Mục (Directory Structure)
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/locpham/travelplanning/
|
||||||
|
├── admin.guard.ts # Middleware kiểm tra quyền admin
|
||||||
|
├── App.tsx # Component chính của ứng dụng frontend
|
||||||
|
├── ARCHITECTURE.md # Tài liệu kiến trúc hệ thống
|
||||||
|
├── CreateTourModal.tsx # Modal cho tạo tour
|
||||||
|
├── dist/ # Thư mục build output
|
||||||
|
├── .env # Biến môi trường
|
||||||
|
├── ExpenseManager.tsx # Component quản lý chi phí
|
||||||
|
├── ExploreMap.tsx # Component bản đồ khám phá
|
||||||
|
├── index.tsx # Điểm vào ứng dụng frontend
|
||||||
|
├── ItineraryTimeline.tsx # Component timeline lịch trình
|
||||||
|
├── jwt-auth.guard.ts # Middleware xác thực JWT
|
||||||
|
├── jwt.strategy.ts # Strategy xử lý JWT
|
||||||
|
├── LandingPage.tsx # Trang landing page
|
||||||
|
├── LoginModal.tsx # Modal đăng nhập
|
||||||
|
├── main.ts # Điểm vào ứng dụng backend
|
||||||
|
├── migrations/ # Migration cũ của ORM khác
|
||||||
|
│ ├── 20260613030135_init_travel_planning_schema/
|
||||||
|
│ │ └── migration.sql
|
||||||
|
│ ├── 20260613091754_add_is_admin_field/
|
||||||
|
│ │ └── migration.sql
|
||||||
|
│ ├── 20260613112158/
|
||||||
|
│ │ └── migration.sql
|
||||||
|
│ ├── 20260613114646/
|
||||||
|
│ │ └── migration.sql
|
||||||
|
│ └── migration_lock.toml
|
||||||
|
├── nest-cli.json # Cấu hình NestJS CLI
|
||||||
|
├── node_modules/ # Thư mục dependencies
|
||||||
|
├── package.json # Cấu hình project Node.js
|
||||||
|
├── package-lock.json # Lockfile dependencies
|
||||||
|
├── postcss.config.js # Cấu hình PostCSS
|
||||||
|
├── prisma/ # Thư mục Prisma ORM
|
||||||
|
│ ├── migrations/
|
||||||
|
│ │ └── 0_init/
|
||||||
|
│ │ └── migration.sql
|
||||||
|
│ ├── schema.prisma # Schema dữ liệu chính
|
||||||
|
│ └── prisma.service.ts # Service Prisma
|
||||||
|
├── rbac.middleware.ts # Middleware phân quyền RBAC
|
||||||
|
├── README.md # Tài liệu hướng dẫn dự án
|
||||||
|
├── schema.sql # File SQL schema
|
||||||
|
├── seed.ts # File seed dữ liệu mẫu
|
||||||
|
├── SignupPage.tsx # Trang đăng ký tài khoản
|
||||||
|
├── tailwind.config.ts # Cấu hình TailwindCSS
|
||||||
|
├── TourDetailPage.tsx # Trang chi tiết tour
|
||||||
|
├── tsconfig.build.json # Cấu hình TypeScript build
|
||||||
|
├── tsconfig.json # Cấu hình TypeScript
|
||||||
|
├── UITourDesign.md # Tài liệu thiết kế UI
|
||||||
|
├── useTourStore.ts # State management Zustand cho Tour
|
||||||
|
├── vite.config.ts # Cấu hình Vite bundler
|
||||||
|
├── .gitignore # Git ignore rules
|
||||||
|
├── .vscode/
|
||||||
|
│ └── settings.json # Cấu hình VS Code
|
||||||
|
└── index.css # Styles CSS toàn cục
|
||||||
|
```
|
||||||
|
|
||||||
|
### Giải thích Cấu trúc Thư mục
|
||||||
|
|
||||||
|
**Frontend Layer:**
|
||||||
|
- Các file `.tsx` (React components) xử lý giao diện người dùng: `LandingPage`, `TourDetailPage`, `CreateTourModal`, `ExpenseManager`, `ItineraryTimeline`, `ExploreMap`, `UserManagementModal`, `SignupPage`, `LoginModal`, `AddLocationModal`
|
||||||
|
- Cấu hình TailwindCSS (`tailwind.config.ts`, `index.css`) cho styling
|
||||||
|
- `useTourStore.ts` - State management với Zustand
|
||||||
|
|
||||||
|
**Backend Layer:**
|
||||||
|
- `main.ts` - Entry point ứng dụng NestJS/Express
|
||||||
|
- `expense-engine.service.ts` - Service tính toán và chia chi phí
|
||||||
|
- `prisma.service.ts` - Service kết nối cơ sở dữ liệu
|
||||||
|
- Guards & Middleware: `admin.guard.ts`, `jwt-auth.guard.ts`, `rbac.middleware.ts`
|
||||||
|
- `jwt.strategy.ts` - Xác thực người dùng qua JWT
|
||||||
|
|
||||||
|
**Database Layer:**
|
||||||
|
- `prisma/schema.prisma` - Định nghĩa schema dữ liệu
|
||||||
|
- `prisma/migrations/` - Lịch sử thay đổi schema của Prisma
|
||||||
|
- `migrations/` - Migration cũ (không dùng Prisma)
|
||||||
|
- `seed.ts` - Chèn dữ liệu mẫu vào cơ sở dữ liệu
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
- `tsconfig.json`, `tsconfig.build.json` - Cấu hình TypeScript
|
||||||
|
- `vite.config.ts` - Config bundler Vite
|
||||||
|
- `nest-cli.json` - Cấu hình NestJS CLI
|
||||||
|
- `postcss.config.js` - Cấu hình PostCSS cho TailwindCSS
|
||||||
|
- `.env` - Biến môi trường ứng dụng
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
- `ARCHITECTURE.md` - Tài liệu kiến trúc hệ thống
|
||||||
|
- `UITourDesign.md` - Tài liệu thiết kế giao diện người dùng
|
||||||
|
- `README.md` - Hướng dẫn cài đặt và chạy dự án
|
||||||
|
|
||||||
|
**Tools & IDE:**
|
||||||
|
- `.vscode/settings.json` - Cấu hình VS Code workspace
|
||||||
|
- `.gitignore` - Quy tắc bỏ qua file Git
|
||||||
|
|
||||||
|
### Công nghệ trong Dự án
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"frontend": {
|
||||||
|
"framework": "React + Vite",
|
||||||
|
"styling": "TailwindCSS + PostCSS",
|
||||||
|
"state_management": "Zustand",
|
||||||
|
"map_engine": "Yêu cầu tích hợp Leaflet/Google Maps API"
|
||||||
|
},
|
||||||
|
"backend": {
|
||||||
|
"framework": "NestJS/Express với TypeScript",
|
||||||
|
"auth": "JWT Strategy + Guards",
|
||||||
|
"orm": "Prisma",
|
||||||
|
"database": "PostgreSQL + PostGIS"
|
||||||
|
},
|
||||||
|
"build_tools": {
|
||||||
|
"bundler": "Vite",
|
||||||
|
"compiler": "TypeScript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
Hệ thống được thiết kế theo mô hình **Client-Server** kết hợp với kiến trúc **Modular Monolith** hoặc **Microservices** (tùy quy mô triển khai), chia làm 3 tầng chính:
|
Hệ thống được thiết kế theo mô hình **Client-Server** kết hợp với kiến trúc **Modular Monolith** hoặc **Microservices** (tùy quy mô triển khai), chia làm 3 tầng chính:
|
||||||
|
|
||||||
@@ -31,44 +149,38 @@ Hệ thống được thiết kế theo mô hình **Client-Server** kết hợp
|
|||||||
|
|
||||||
Dưới đây là các thực thể cốt lõi phục vụ tính năng:
|
Dưới đây là các thực thể cốt lõi phục vụ tính năng:
|
||||||
|
|
||||||
### 3.1. Users & Authentication
|
### 3.1. User (Người dùng)
|
||||||
* `users`: Lưu thông tin định danh (`id`, `email`, `password_hash`, `name`, `avatar`).
|
Lưu trữ thông tin định danh và trạng thái quản trị. Toàn bộ ID sử dụng định dạng **UUID**.
|
||||||
|
* `User`: `id`, `email`, `passwordHash`, `name`, `avatar`, `isAdmin`, `isBlocked`, `createdAt`.
|
||||||
|
|
||||||
### 3.2. Tour & Members (Quản lý đa người dùng & Phân quyền)
|
|
||||||
* `tours`: Thông tin tổng quan về chuyến đi.
|
|
||||||
* `id` (PK), `title`, `start_date`, `end_date`, `creator_id` (FK), `created_at`.
|
|
||||||
* `tour_members`: Bảng trung gian quản lý thành viên và quyền hạn (Bảo mật thông tin).
|
|
||||||
* `tour_id` (FK), `user_id` (FK).
|
|
||||||
* `role`: Định nghĩa các quyền cụ thể:
|
|
||||||
* `OWNER`: Toàn quyền (Người tạo).
|
|
||||||
* `EDITOR`: Sửa đổi kế hoạch, chi phí, xem/thêm ảnh.
|
|
||||||
* `MEMBER_PLAN_ONLY`: Chỉ xem/sửa kế hoạch, không xem được chi phí.
|
|
||||||
* `MEMBER_PHOTO_ONLY`: Chỉ được xem/đăng ảnh trong album, không thấy kế hoạch và chi phí.
|
|
||||||
* `VIEWER_EXTERNAL`: Người ngoài được share link, chỉ xem được ảnh công khai (tùy thuộc vào cài đặt privacy).
|
|
||||||
|
|
||||||
### 3.3. Itinerary & Map (Chặng & Địa điểm)
|
### 3.2. Tour & Phân quyền (RBAC)
|
||||||
* `legs` (Chặng): Một tour có nhiều chặng.
|
Hệ thống sử dụng bảng trung gian để quản lý thành viên cho từng chuyến đi.
|
||||||
* `id` (PK), `tour_id` (FK), `sequence_number` (Thứ tự chặng: 1, 2, 3...), `notes`.
|
* `Tour`: `id`, `title`, `startDate`, `endDate`, `totalCost` (Decimal), `createdById` (FK -> User).
|
||||||
* `places` (Địa điểm trong chặng):
|
* `TourParticipant`: Quản lý vai trò thành viên trong Tour qua enum `ParticipantRole`:
|
||||||
* `id` (PK), `leg_id` (FK), `name`, `address`, `latitude`, `longitude` (Dữ liệu PostGIS), `sequence_in_leg`.
|
* `OWNER`: Toàn quyền quản lý.
|
||||||
* `arrival_time` (Dự kiến), `departure_time` (Dự kiến).
|
* `MANAGER`: Quản lý nội dung và thành viên.
|
||||||
|
* `MEMBER`: Thành viên chính thức (Xem được chi phí).
|
||||||
|
* `MEMBER_NO_FINANCE`: Thành viên không được xem thông tin tài chính.
|
||||||
|
* `VIEWER_ONLY`: Chỉ xem lộ trình và ảnh.
|
||||||
|
|
||||||
### 3.4. Expenses (Chi phí)
|
### 3.3. Lộ trình (Leg & Location)
|
||||||
* `expenses`: Lưu vết chi phí cho từng địa điểm/chặng.
|
Một Tour được chia thành nhiều chặng di chuyển (Leg), mỗi chặng chứa danh sách các điểm đến (Location).
|
||||||
* `id` (PK), `leg_id` (FK), `place_id` (FK, nullable), `category` (`LODGING`, `DINING`, `TRANSPORT`, `OTHER`), `amount` (Số tiền), `currency`, `description`.
|
* `Leg`: `id`, `tourId` (FK), `sequence` (Thứ tự chặng), `note`.
|
||||||
* `tour_cost_summaries`: Cấu hình phân chia tiền ở cuối tour/chặng.
|
* `Location`: Tích hợp tính năng theo dõi tiến độ (Thay thế khái niệm `Task` cũ).
|
||||||
* `tour_id` (FK), `total_cost`, `adult_count`, `child_count`, `child_discount_percent` (Ví dụ: trẻ em giảm 30%).
|
* Tọa độ: `latitude`, `longitude`.
|
||||||
|
* Thời gian: `plannedStart`, `plannedEnd`, `actualStart`, `actualEnd`.
|
||||||
|
* Trạng thái (`LocationStatus`): `PENDING` (Chờ), `COMPLETED` (Hoàn thành).
|
||||||
|
* Loại địa điểm (`LocationType`): `MOVE`, `VISIT`, `REST`, `EAT`.
|
||||||
|
|
||||||
### 3.5. Tasks & Timeline Tracking (Theo dõi tiến độ)
|
### 3.4. Chi phí (Expense)
|
||||||
* `tasks`: Các hoạt động cần tích chọn hoàn thành.
|
Quản lý tài chính cho từng chặng hoặc gắn trực tiếp vào một địa điểm cụ thể.
|
||||||
* `id` (PK), `tour_id` (FK), `leg_id` (FK), `title`, `planned_timestamp`.
|
* `Expense`: `id` (UUID, PK), `leg_id` (UUID, FK), `location_id` (UUID, FK, Nullable), `category` (Enum: `ACCOMMODATION`, `FOOD`, `TRANSPORT`, `TICKET`, `OTHER`), `amount` (Decimal), `description` (Text).
|
||||||
* `is_completed` (Boolean).
|
|
||||||
* `completed_at` (Timestamp - Ghi lại lúc hệ thống tự động tích hoặc user chủ động tích).
|
|
||||||
* `trigger_type` (`AUTO_BY_TIME` hoặc `MANUAL_BY_USER`).
|
|
||||||
|
|
||||||
### 3.6. Album Ảnh (Tách biệt logic kế hoạch)
|
### 3.5. Album Ảnh (Photo)
|
||||||
* `photos`: Lưu trữ hình ảnh của tour gắn với địa điểm.
|
Lưu trữ tài nguyên đa phương tiện gắn với bối cảnh chuyến đi.
|
||||||
* `id` (PK), `tour_id` (FK), `place_id` (FK, nullable), `uploader_id` (FK), `image_url` (Đường dẫn S3), `captured_at` (Metadata từ ảnh hoặc thời gian tạo), `privacy_level` (`PUBLIC_IN_TOUR`, `PRIVATE_OWNER`).
|
* `Photo`: `id`, `tourId` (FK), `locationId` (FK, nullable), `uploaderId` (FK), `imageUrl`, `privacy`.
|
||||||
|
* Cấp độ bảo mật (`PrivacyLevel`): `PUBLIC`, `TOUR_ONLY`, `PRIVATE`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -118,3 +230,231 @@ Dưới đây là các thực thể cốt lõi phục vụ tính năng:
|
|||||||
3. Khi một **User khác** truy cập link Tour:
|
3. Khi một **User khác** truy cập link Tour:
|
||||||
* Hệ thống check `tour_members`.
|
* Hệ thống check `tour_members`.
|
||||||
* Nếu thuộc diện *External Viewer* hoặc *Photo Only*: Ẩn hoàn toàn tab "Kế hoạch chặng hành trình" và "Tổng số tiền", chỉ hiển thị giao diện Grid hình ảnh (`album`).
|
* Nếu thuộc diện *External Viewer* hoặc *Photo Only*: Ẩn hoàn toàn tab "Kế hoạch chặng hành trình" và "Tổng số tiền", chỉ hiển thị giao diện Grid hình ảnh (`album`).
|
||||||
|
|
||||||
|
# 6. THIẾT KẾ GIAO DIỆN NGƯỜI DÙNG (TOUR DASHBOARD & MAP ITINERARY)
|
||||||
|
|
||||||
|
Đây là màn hình Hub chính sau khi người dùng truy cập vào một Tour cụ thể. Giao diện được thiết kế để tối ưu hóa trải nghiệm trên thiết bị di động (Mobile-first) và kiểm soát hiển thị nội dung động dựa trên vai trò của thành viên (`ParticipantRole`).
|
||||||
|
|
||||||
|
## 6.1. Thành phần Giao diện chính
|
||||||
|
|
||||||
|
* **Top Banner (Khu vực tiêu đề):**
|
||||||
|
* **Cover Image:** Ảnh bìa của tour (lấy từ ảnh đầu tiên trong Album hoặc ảnh phong cảnh mặc định).
|
||||||
|
* **Thông tin Tour:** Hiển thị tên Tour và khoảng thời gian diễn ra (`startDate` -> `endDate`).
|
||||||
|
* **Member Avatars:** Danh sách avatar các thành viên tham gia (`TourParticipant`). Cho phép nhấn vào để xem chi tiết hoặc mời thêm người (nếu có quyền).
|
||||||
|
|
||||||
|
|
||||||
|
* **Financial Quick-View Widget (Khối tài chính nhanh):**
|
||||||
|
* Hiển thị `totalCost` hiện tại của toàn bộ chuyến đi.
|
||||||
|
* **Logic Ẩn/Hiện động (RBAC):**
|
||||||
|
* *Hiển thị:* Với các role `OWNER`, `MANAGER`, `MEMBER`.
|
||||||
|
* *Ẩn:* Với các role `MEMBER_NO_FINANCE` hoặc `VIEWER_ONLY`. Thay thế khối này bằng một câu quote truyền cảm hứng du lịch (Ví dụ: *"Đừng nghe họ nói, hãy tự mình đi xem"*).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
* **Bottom Navigation Bar hoặc Tab View:** Hệ thống chia làm 4 tab chính để tránh nhồi nhét dữ liệu:
|
||||||
|
1. **Lộ trình (Itinerary):** Hiển thị danh sách `Leg` & `Location` tích hợp Bản đồ tương tác. *Đây là tab mặc định.*
|
||||||
|
2. **Chi phí (Finance):** Quản lý các `Expense`. Chỉ hiển thị cho các role có quyền truy cập tài chính.
|
||||||
|
3. **Album ảnh (Gallery):** Hiển thị lưới hình ảnh `Photo` được chia sẻ trong chuyến đi.
|
||||||
|
4. **Thành viên & Cài đặt (Settings):** Khu vực dành riêng cho `OWNER` hoặc `MANAGER` để quản lý thành viên, phân quyền và cấu hình tour.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.2. Sơ đồ bố cục tổng thể (Wireframe mô phỏng)
|
||||||
|
|
||||||
|
```text
|
||||||
|
+---------------------------------------+
|
||||||
|
| [ < ] Tên Chuyến Đi [ . . ] | <-- Header bar
|
||||||
|
+---------------------------------------+
|
||||||
|
| |
|
||||||
|
| IMAGE COVER PHOTO | <-- Top Banner
|
||||||
|
| |
|
||||||
|
| [Avatar][Avatar][Avatar] +3 |
|
||||||
|
+---------------------------------------+
|
||||||
|
| |
|
||||||
|
| { TỔNG CHI PHÍ: 2.500.000 VND } | <-- Finance Widget (Hoặc Quote)
|
||||||
|
| |
|
||||||
|
+---------------------------------------+
|
||||||
|
| [ Lộ trình ] [ Chi phí ] [ Ảnh ] [S] | <-- Tab Navigation
|
||||||
|
+---------------------------------------+
|
||||||
|
| |
|
||||||
|
| TAB LỘ TRÌNH (BẢN ĐỒ + DANH SÁCH) |
|
||||||
|
| |
|
||||||
|
+---------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.3. Tab Lộ Trình: Bản Đồ Tương Tác & Cấu Trúc Chặng (Map-Driven Timeline)
|
||||||
|
|
||||||
|
Để tối ưu hóa UX trên di động, Tab Lộ trình được chia thành 2 chế độ hiển thị bằng một nút bấm chuyển đổi nhanh (Toggle Switch): **Chế độ Bản đồ (Map View)** và **Chế độ Danh sách (Timeline View)**. Khi chỉnh sửa/khởi tạo, Chế độ Bản đồ sẽ làm chủ đạo.
|
||||||
|
|
||||||
|
### A. Luồng Khởi Tạo & Định Vị Lộ Trình (Google Maps Style)
|
||||||
|
|
||||||
|
Đối với các tài khoản có quyền chỉnh sửa (`OWNER`, `MANAGER`), luồng thiết lập lộ trình không gian được thực hiện qua các bước tương tác trực quan:
|
||||||
|
|
||||||
|
#### Giai đoạn 1: Đóng khung điểm Đầu - Cuối
|
||||||
|
|
||||||
|
1. **Khai báo Tour:** User nhập thông tin cơ bản ở bảng cấu hình.
|
||||||
|
2. **Xác định Điểm Đầu:** Tìm kiếm trên ô Search của bản đồ hoặc rê chuột/di tâm màn hình đến vị trí mong muốn ➔ **Click chuột phải** (hoặc **Nhấn giữ** nếu dùng mobile) ➔ Chọn **"Bắt đầu từ đây"**.
|
||||||
|
* *Phản hồi UI:* Xuất hiện Marker màu Xanh lá `[S]`. Tạo bản ghi `Location` đầu tiên.
|
||||||
|
|
||||||
|
|
||||||
|
3. **Xác định Điểm Cuối:** Di chuyển đến điểm đích ➔ **Click chuột phải / Nhấn giữ** ➔ Chọn **"Kết thúc ở đây"**.
|
||||||
|
* *Phản hồi UI:* Xuất hiện Marker màu Đỏ `[E]`.
|
||||||
|
|
||||||
|
|
||||||
|
4. **Khai báo số lượng Chặng:** Một ô thông báo nổi (Pop-up) yêu cầu nhập: *"Chuyến đi này chia làm bao nhiêu chặng?"*. Khi nhập số $N$, hệ thống tự động sinh ra $N$ bản ghi `Leg` với `sequence` từ $1$ đến $N$ ở trạng thái chờ rỗng.
|
||||||
|
|
||||||
|
#### Giai đoạn 2: Ghim điểm tham quan vào Chặng (Map-to-Leg)
|
||||||
|
|
||||||
|
1. **Chấm điểm:** Người dùng gõ tìm kiếm tọa độ/địa danh hoặc click trực tiếp lên bản đồ.
|
||||||
|
2. **Phân bổ vào chặng:** Click chuột phải / Nhấn giữ vào điểm vừa chọn ➔ Menu ngữ cảnh hiện ra danh sách các chặng đã khai báo ở Giai đoạn 1 ➔ Chọn số thứ tự chặng (Ví dụ: `Thêm vào Chặng 1`).
|
||||||
|
3. **Tự động sắp xếp & Vẽ tuyến:** * Dữ liệu địa điểm lập tức được xếp vào mảng con của `Leg` có `sequence: 1`.
|
||||||
|
* Hệ thống tự động sắp xếp vị trí hiển thị theo thứ tự thêm vào (có thể kéo thả đổi thứ tự thủ công sau).
|
||||||
|
* Bản đồ tự động gọi API Routing để vẽ đường nối liền mạch: **`[S]` ➔ Các điểm Chặng 1 ➔ Các điểm Chặng 2 ➔ `[E]**`.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#### Giai đoạn 3: Tối ưu Lịch trình & Thời gian (Data Refinement)
|
||||||
|
|
||||||
|
Sau khi "bộ khung" không gian trên bản đồ đã hoàn tất, người dùng chuyển sang giao diện danh sách để cấu hình chi tiết tuyến tính thời gian:
|
||||||
|
|
||||||
|
* Bổ sung ngày giờ kế hoạch (`plannedStart`, `plannedEnd`) cho từng `Location`.
|
||||||
|
* Chọn loại địa điểm (`LocationType`: `MOVE`, `VISIT`, `REST`, `EAT`) để hệ thống đồng bộ Icon hiển thị.
|
||||||
|
* Thêm ghi chú (`note`) tổng quan cho từng `Leg`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
WIREFRAME CHẾ ĐỘ BẢN ĐỒ (MAP VIEW MODE):
|
||||||
|
+---------------------------------------+
|
||||||
|
| [ Tìm kiếm địa điểm trên bản đồ... ] |
|
||||||
|
+---------------------------------------+
|
||||||
|
| |
|
||||||
|
| [S] (Điểm xuất phát) |
|
||||||
|
| \ |
|
||||||
|
| \____ [Icon: EAT] Nhà hàng A |
|
||||||
|
| \ |
|
||||||
|
| \____ [E] (Điểm cuối) |
|
||||||
|
| |
|
||||||
|
| CONTEXT MENU KHI NHẤN GIỮ/CHUỘT PHẢI: |
|
||||||
|
| +---------------------------------+ |
|
||||||
|
| | o Bắt đầu từ đây | |
|
||||||
|
| | o Kết thúc ở đây | |
|
||||||
|
| | o Thêm vào lộ trình > [Chặng 1]| |
|
||||||
|
| +------------------------[Chặng 2]| |
|
||||||
|
+---------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B. Chế Độ Hiển Thị Timeline Đứng (Dành cho việc Theo Dõi Tiến Độ)
|
||||||
|
|
||||||
|
Sau khi lưu lộ trình, khi đi du lịch thực tế, người dùng sẽ chủ yếu xem ở giao diện **Timeline đứng** để kiểm tra tiến độ:
|
||||||
|
|
||||||
|
#### Tầng 1 - Chọn Chặng (Leg Picker)
|
||||||
|
|
||||||
|
* **UI Component:** Sử dụng **Horizontal Tab Scroller** đặt ở phía trên cùng của tab.
|
||||||
|
* **Chức năng:** Người dùng có thể chọn nhanh các Chặng (ví dụ: Chặng 1, Chặng 2, Chặng 3...) để lọc dữ liệu hiển thị, tránh kéo màn hình quá dài.
|
||||||
|
* **Logic:** Nhấn vào `Leg` nào, danh sách các `Location` được xếp theo đúng `sequence` của chặng đó mới hiển thị phía dưới.
|
||||||
|
|
||||||
|
#### Tầng 2 - Danh sách Địa điểm (Location Cards)
|
||||||
|
|
||||||
|
Mỗi địa điểm là một thẻ (card) nằm trên đường timeline đứng, bao gồm các đặc điểm nhận diện:
|
||||||
|
|
||||||
|
* **Icon nhận diện theo loại (LocationType):**
|
||||||
|
* `MOVE`: Icon Phương tiện (Xe bus, Máy bay).
|
||||||
|
* `VISIT`: Icon Tham quan (Kính thiên văn, Lá cờ).
|
||||||
|
* `REST`: Icon Nghỉ ngơi (Giường ngủ).
|
||||||
|
* `EAT`: Icon Ẩm thực (Dao dĩa).
|
||||||
|
|
||||||
|
|
||||||
|
* **Trạng thái & Tiến độ (LocationStatus):**
|
||||||
|
* **Trạng thái PENDING:** * *UI:* Card có viền nét đứt (dashed), màu xám nhạt. Hiển thị giờ dự kiến (`plannedStart`).
|
||||||
|
* *Hành động:* Nút "Bắt đầu" (Chỉ hiển thị cho vai trò `OWNER` hoặc `MANAGER`).
|
||||||
|
|
||||||
|
|
||||||
|
* **Trạng thái Đang diễn ra (Active):**
|
||||||
|
* *UI:* Card được làm sáng (highlight), hiển thị giờ bắt đầu thực tế (`actualStart`).
|
||||||
|
* *Hành động:* Nút "Hoàn thành".
|
||||||
|
|
||||||
|
|
||||||
|
* **Trạng thái COMPLETED:**
|
||||||
|
* *UI:* Card có dấu tích xanh, màu nền chuyển sang tone dịu (ví dụ: xanh lá nhạt), hiển thị giờ kết thúc thực tế (`actualEnd`).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.4. Tab Chi Phí (Expense Management)
|
||||||
|
|
||||||
|
Cấu trúc dữ liệu của hệ thống cho phép gắn `Expense` (Chi phí) vào một `Leg` (Chặng) hoặc gắn trực tiếp vào một `Location` (Địa điểm) cụ thể.
|
||||||
|
|
||||||
|
### A. Phân tích trực quan
|
||||||
|
|
||||||
|
* **Biểu đồ tròn (Donut Chart):** Hiển thị ở đầu tab để phân tích chi phí theo danh mục (`category`: FOOD, TRANSPORT, ACCOMMODATION, TICKET...). Giúp người dùng nhìn trực quan ngân sách đang đổ vào đâu nhiều nhất.
|
||||||
|
|
||||||
|
### B. Danh sách hiển thị (List View)
|
||||||
|
|
||||||
|
* **Hiển thị dòng tiền:** Liệt kê các khoản chi tiêu kèm mô tả (`description`) và số tiền chính xác (`amount`).
|
||||||
|
* **Nhãn bối cảnh (Context Tag):** Mỗi dòng chi phí có một nhãn nhỏ đi kèm giúp người dùng xác định bối cảnh chi tiêu:
|
||||||
|
* Nếu `location_id` có dữ liệu: Hiển thị nhãn **“Tại: [Tên địa điểm]”**.
|
||||||
|
* Nếu chỉ có `leg_id`: Hiển thị nhãn **“Thuộc: [Tên Chặng]”**.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### C. Nút thêm nhanh (Floating Action Button)
|
||||||
|
|
||||||
|
* **Nút +:** Nằm ở góc dưới màn hình để thêm mới chi phí.
|
||||||
|
* **Logic thông minh:** Khi nhấn vào, hệ thống sẽ tự động bắt bối cảnh (Context-aware). Nếu user đang đứng xem ở `Chặng 2`, form thêm mới sẽ tự động chọn sẵn `leg_id` của Chặng 2 để giảm thiểu các bước nhập liệu thủ công.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.5. Tab Album Ảnh (Contextual Photo Gallery)
|
||||||
|
|
||||||
|
Bảng `Photo` có trường `locationId` (nullable) và `privacy`. Giao diện cần dạng hóa việc lọc để tối ưu trải nghiệm xem lại kỷ niệm.
|
||||||
|
|
||||||
|
### A. Chế độ xem thông minh (Smart View)
|
||||||
|
|
||||||
|
Cung cấp 2 chế độ lọc thông qua nút gạt (Toggle Switch):
|
||||||
|
|
||||||
|
* **Xem theo dòng thời gian (Timeline Photo):** Ảnh được gom nhóm (`group by`) theo từng `Location`. Đi tới địa điểm nào trên bản đồ/timeline, ảnh chụp tại đó sẽ hiện ngay bên dưới địa điểm đó.
|
||||||
|
* **Xem dạng lưới (Grid View):** Hiển thị dạng lưới ảnh tiêu chuẩn (3x3 hoặc 4x4), tương tự các ứng dụng quản lý ảnh gốc trên điện thoại.
|
||||||
|
|
||||||
|
### B. Bộ lọc Quyền riêng tư (PrivacyLevel) & Chỉ báo UI
|
||||||
|
|
||||||
|
* Các ảnh có tag `PRIVATE` chỉ hiển thị duy nhất với chính người tải lên (`uploaderId`).
|
||||||
|
* Ảnh `TOUR_ONLY` hiển thị cho mọi thành viên có trong Tour.
|
||||||
|
* **Chỉ báo UI (Privacy Indicators):** Trên góc mỗi ảnh hiển thị một icon nhỏ (Hình ổ khóa cho `PRIVATE`, Hình con mắt cho `TOUR_ONLY` hoặc `PUBLIC`) giúp người dùng dễ dàng kiểm soát trạng thái chia sẻ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Tối ưu hóa State Management & Hiệu năng Frontend (Crucial)
|
||||||
|
|
||||||
|
Vì cấu trúc dữ liệu của dự án sử dụng rất nhiều ID nối nhau (Foreign Keys), để frontend chạy mượt và không bị giật lag khi render, các kỹ thuật sau được ưu tiên áp dụng:
|
||||||
|
|
||||||
|
### 7.1. Chuẩn hóa dữ liệu ở Frontend (Data Normalization)
|
||||||
|
Khi gọi API lấy chi tiết Tour, cấu trúc cây sẽ được biến đổi thành cấu trúc phẳng (Flatten State) bằng cách lưu dữ liệu dưới dạng Object Key-Value (Dùng ID làm Key).
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Ví dụ cấu trúc State tối ưu ở Frontend
|
||||||
|
const locationsState = {
|
||||||
|
"uuid-location-1": { latitude: 16.0, longitude: 108.0, status: "PENDING", ... },
|
||||||
|
"uuid-location-2": { ... }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
Khi một Location thay đổi trạng thái từ `PENDING` sang `COMPLETED`, hệ thống chỉ cần cập nhật đúng object đó thông qua ID, giúp các thành phần khác của UI không bị re-render vô ích, tối ưu hóa hiệu năng render của React.
|
||||||
|
|
||||||
|
### 7.2. Xử lý Real-time và Optimistic Updates (Cập nhật lạc quan)
|
||||||
|
* **Đồng bộ Real-time:** Sử dụng **Websocket** để đồng bộ trạng thái giữa các thành viên trong Tour. Khi một người bấm "Hoàn thành" địa điểm hoặc "Thêm chi phí", những người khác sẽ nhận được cập nhật ngay lập tức.
|
||||||
|
* **Optimistic Update:** Khi một thành viên thêm một `Expense`, Frontend sẽ lập tức cộng số tiền đó vào `totalCost` hiển thị trên màn hình trước khi nhận phản hồi từ server. Nếu API trả về lỗi, hệ thống sẽ thực hiện roll-back trạng thái dữ liệu. Điều này tạo cảm giác ứng dụng phản hồi tức thì.
|
||||||
|
|
||||||
|
### 7.3. Phân quyền UI động (Dynamic UI Rendering based on Role)
|
||||||
|
Sử dụng các hàm Helper tại Frontend để kiểm tra quyền hạn trước khi render các thành phần tương tác, đảm bảo tính bảo mật và trải nghiệm người dùng:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const canEdit = ['OWNER', 'MANAGER'].includes(userRole);
|
||||||
|
|
||||||
|
// Trên UI Component:
|
||||||
|
{canEdit && <Button onClick={handleEditLocation}>Chỉnh sửa lộ trình</Button>}
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { X, MapPin, Loader2, Clock, Map as MapIcon } from 'lucide-react';
|
||||||
|
import { useTourStore } from './useTourStore.js';
|
||||||
|
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
|
||||||
|
import L from 'leaflet';
|
||||||
|
|
||||||
|
// Cấu hình Icon mặc định để tránh crash Marker trong Modal
|
||||||
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||||
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||||
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Component Helper xử lý việc chọn vị trí trên mini map bằng chuột phải
|
||||||
|
const MapPicker = ({ onPick, center }: { onPick: (latlng: L.LatLng) => void, center: [number, number] }) => {
|
||||||
|
const [menuPos, setMenuPos] = useState<{ x: number, y: number, latlng: L.LatLng } | null>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
useMapEvents({
|
||||||
|
contextmenu: (e) => {
|
||||||
|
L.DomEvent.preventDefault(e.originalEvent);
|
||||||
|
L.DomEvent.stopPropagation(e.originalEvent);
|
||||||
|
setMenuPos({ x: e.containerPoint.x, y: e.containerPoint.y, latlng: e.latlng });
|
||||||
|
},
|
||||||
|
click: () => setMenuPos(null),
|
||||||
|
dragstart: () => setMenuPos(null),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (menuPos && menuRef.current) {
|
||||||
|
L.DomEvent.disableClickPropagation(menuRef.current);
|
||||||
|
}
|
||||||
|
}, [menuPos]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
map.setView(center, map.getZoom());
|
||||||
|
}, [center]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{menuPos && (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="absolute z-[3000] bg-white rounded-xl shadow-xl border border-gray-100 py-1 w-44 animate-in zoom-in-95 duration-200"
|
||||||
|
style={{ top: menuPos.y, left: menuPos.x }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onPick(menuPos.latlng); setMenuPos(null); }}
|
||||||
|
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-xs font-bold text-blue-600 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<MapPin className="w-3 h-3" /> Thêm vào chặng hiện tại
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any }) => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
address: '',
|
||||||
|
latitude: 10.7769,
|
||||||
|
longitude: 106.7009,
|
||||||
|
type: 'VISIT',
|
||||||
|
legId: '',
|
||||||
|
note: '',
|
||||||
|
expenseAmount: '',
|
||||||
|
expenseCategory: 'OTHER',
|
||||||
|
expenseDescription: '',
|
||||||
|
expenseNote: '',
|
||||||
|
paidById: '',
|
||||||
|
plannedStart: '',
|
||||||
|
plannedEnd: ''
|
||||||
|
});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Gom các selector lại để giảm số lượng Hook gọi nội bộ và tăng hiệu năng
|
||||||
|
const { legs, addLocation, updateLocation, mapCenter, currentTour } = useTourStore();
|
||||||
|
|
||||||
|
// 1. Luôn khai báo Hook ở cấp cao nhất, không đặt code logic/return giữa các Hook
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
if (editingLocation) {
|
||||||
|
const leg = legs.find(l => l.id === editingLocation.legId);
|
||||||
|
const expense = leg?.expenses?.find((e: any) => e.locationId === editingLocation.id);
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
name: editingLocation.name || '',
|
||||||
|
address: editingLocation.address || '',
|
||||||
|
latitude: editingLocation.latitude,
|
||||||
|
longitude: editingLocation.longitude,
|
||||||
|
type: editingLocation.type || 'VISIT',
|
||||||
|
legId: editingLocation.legId || '',
|
||||||
|
note: editingLocation.note || '',
|
||||||
|
expenseAmount: expense?.amount?.toString() || '',
|
||||||
|
expenseCategory: expense?.category || 'OTHER',
|
||||||
|
expenseDescription: expense?.description || '',
|
||||||
|
expenseNote: expense?.note || '',
|
||||||
|
paidById: expense?.paidById || '',
|
||||||
|
plannedStart: editingLocation.plannedStart ? editingLocation.plannedStart.slice(0, 16) : '',
|
||||||
|
plannedEnd: editingLocation.plannedEnd ? editingLocation.plannedEnd.slice(0, 16) : ''
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
name: '', address: '',
|
||||||
|
legId: initialLegId || (legs.length > 0 ? legs[0].id : ''),
|
||||||
|
note: '', expenseAmount: '', expenseCategory: 'OTHER',
|
||||||
|
expenseDescription: '', expenseNote: '', paidById: '',
|
||||||
|
plannedStart: '', plannedEnd: ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [initialLegId, editingLocation, isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && !formData.name) {
|
||||||
|
setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] }));
|
||||||
|
}
|
||||||
|
}, [isOpen, mapCenter]);
|
||||||
|
|
||||||
|
// 2. Thực hiện các tính toán và hàm xử lý
|
||||||
|
const currentLegId = formData.legId || (legs.length > 0 ? legs[0].id : '');
|
||||||
|
const targetLeg = legs.find(l => l.id === currentLegId);
|
||||||
|
const titleText = editingLocation ? `Sửa địa điểm: ${editingLocation.name}` : (initialLegId ? `Thêm địa điểm cho ${targetLeg?.note || `Chặng ${targetLeg?.sequence}`}` : 'Thêm địa điểm mới');
|
||||||
|
const buttonText = editingLocation ? 'Cập nhật thay đổi' : (initialLegId ? 'Xác nhận thêm vào chặng' : 'Thêm địa điểm');
|
||||||
|
|
||||||
|
const handlePickLocation = async (latlng: L.LatLng) => {
|
||||||
|
setFormData(prev => ({ ...prev, latitude: latlng.lat, longitude: latlng.lng }));
|
||||||
|
|
||||||
|
// Tự động lấy tên địa điểm từ tọa độ vừa chọn
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latlng.lat}&lon=${latlng.lng}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const addr = data.address;
|
||||||
|
const detectedName = addr.amenity || addr.building || addr.historic || addr.tourist ||
|
||||||
|
addr.shop || addr.office || addr.leisure || addr.attraction ||
|
||||||
|
addr.road || addr.neighbourhood || addr.suburb ||
|
||||||
|
data.display_name?.split(',')[0] || "";
|
||||||
|
|
||||||
|
setFormData(prev => ({ ...prev, name: detectedName || prev.name, address: data.display_name || prev.address }));
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Early return phải nằm SAU tất cả các khai báo Hook
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const payload: any = {
|
||||||
|
...formData,
|
||||||
|
legId: currentLegId,
|
||||||
|
latitude: parseFloat(formData.latitude as any),
|
||||||
|
longitude: parseFloat(formData.longitude as any),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingLocation) {
|
||||||
|
await updateLocation(editingLocation.id, payload);
|
||||||
|
} else {
|
||||||
|
await addLocation(tourId, payload);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Lỗi khi lưu địa điểm');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
|
||||||
|
<div className="relative w-full max-w-lg bg-white rounded-3xl shadow-2xl overflow-hidden p-8 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<MapIcon className="w-6 h-6 text-blue-600" /> {titleText}
|
||||||
|
</h2>
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||||
|
<X className="w-6 h-6 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mini Map Picker */}
|
||||||
|
<div className="h-48 w-full rounded-2xl overflow-hidden mb-6 border border-gray-100 relative shadow-inner group">
|
||||||
|
<MapContainer center={[formData.latitude, formData.longitude]} zoom={13} className="h-full w-full">
|
||||||
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||||
|
<Marker position={[formData.latitude, formData.longitude]} />
|
||||||
|
<MapPicker center={[formData.latitude, formData.longitude]} onPick={handlePickLocation} />
|
||||||
|
</MapContainer>
|
||||||
|
<div className="absolute bottom-2 left-2 z-[1000] bg-white/90 backdrop-blur-sm px-2 py-1 rounded-lg text-[10px] font-black text-gray-500 shadow-sm border border-gray-100">
|
||||||
|
CHUỘT PHẢI ĐỂ CHỌN VỊ TRÍ
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Tên địa điểm</label>
|
||||||
|
<input required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||||
|
value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Địa chỉ</label>
|
||||||
|
<input className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||||
|
value={formData.address} onChange={e => setFormData({...formData, address: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Ghi chú địa điểm / Dịch vụ sử dụng</label>
|
||||||
|
<textarea className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none resize-none" rows={2}
|
||||||
|
placeholder="Ví dụ: Ăn trưa tại quán X, thuê hướng dẫn viên..."
|
||||||
|
value={formData.note} onChange={e => setFormData({...formData, note: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50/60 border border-blue-100 rounded-2xl p-4 space-y-3">
|
||||||
|
<p className="text-xs font-black text-blue-500 uppercase tracking-widest">Chi phí nhanh tại điểm này</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-gray-600 mb-1">Số tiền (VNĐ)</label>
|
||||||
|
<input type="number" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||||
|
placeholder="0"
|
||||||
|
value={formData.expenseAmount} onChange={e => setFormData({...formData, expenseAmount: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-gray-600 mb-1">Loại dịch vụ</label>
|
||||||
|
<select className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||||
|
value={formData.expenseCategory} onChange={e => setFormData({...formData, expenseCategory: e.target.value})}>
|
||||||
|
<option value="FOOD">Ăn uống</option>
|
||||||
|
<option value="TRANSPORT">Di chuyển</option>
|
||||||
|
<option value="ACCOMMODATION">Chỗ ở</option>
|
||||||
|
<option value="TICKET">Vé tham quan</option>
|
||||||
|
<option value="OTHER">Khác</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-gray-600 mb-1">Dịch vụ / Mô tả</label>
|
||||||
|
<input className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||||
|
placeholder="Ví dụ: Ăn trưa, taxi, vé..."
|
||||||
|
value={formData.expenseDescription} onChange={e => setFormData({...formData, expenseDescription: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-gray-600 mb-1">Ghi chú chi phí</label>
|
||||||
|
<textarea className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none resize-none text-sm" rows={2}
|
||||||
|
placeholder="Ghi chú thêm..."
|
||||||
|
value={formData.expenseNote} onChange={e => setFormData({...formData, expenseNote: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-gray-600 mb-1">Thành viên đã thanh toán</label>
|
||||||
|
<select className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||||
|
value={formData.paidById} onChange={e => setFormData({...formData, paidById: e.target.value})}>
|
||||||
|
<option value="">-- Chọn người thanh toán --</option>
|
||||||
|
{currentTour?.participants?.map((p: any) => {
|
||||||
|
const name = p.user?.name;
|
||||||
|
const email = p.user?.email;
|
||||||
|
const label = [name, email && name ? `(${email})` : email].filter(Boolean).join(' ');
|
||||||
|
return (
|
||||||
|
<option key={p.userId} value={p.userId}>{label || p.userId}</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Gán vào chặng</label>
|
||||||
|
<select required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||||
|
value={currentLegId} onChange={e => setFormData({...formData, legId: e.target.value})}>
|
||||||
|
{legs.map(leg => (
|
||||||
|
<option key={leg.id} value={leg.id}>Chặng {leg.sequence}: {leg.note || 'Không có tên'}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Vĩ độ</label>
|
||||||
|
<input type="number" step="any" required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||||
|
value={formData.latitude} onChange={e => setFormData({...formData, latitude: e.target.value as any})} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Kinh độ</label>
|
||||||
|
<input type="number" step="any" required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||||
|
value={formData.longitude} onChange={e => setFormData({...formData, longitude: e.target.value as any})} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Loại</label>
|
||||||
|
<select className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||||
|
value={formData.type} onChange={e => setFormData({...formData, type: e.target.value as any})}>
|
||||||
|
<option value="VISIT">Tham quan</option>
|
||||||
|
<option value="EAT">Ăn uống</option>
|
||||||
|
<option value="REST">Nghỉ ngơi</option>
|
||||||
|
<option value="MOVE">Di chuyển</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Bắt đầu</label>
|
||||||
|
<input type="datetime-local" className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||||
|
value={formData.plannedStart} onChange={e => setFormData({...formData, plannedStart: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button disabled={isLoading} className="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl flex items-center justify-center gap-2 mt-4">
|
||||||
|
{isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : buttonText}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { X, Search, UserPlus, Loader2, Shield, Trash2, Clock, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AddMemberModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
tourId: string;
|
||||||
|
participants?: Array<{ userId: string; role: string; user?: { id: string; name: string; email: string } }>;
|
||||||
|
joinRequests?: Array<{ id: string; userId: string; user?: { id: string; name: string; email: string }; status: string; requestedById: string }>;
|
||||||
|
onRemoveMember?: (userId: string) => Promise<void>;
|
||||||
|
onMemberAdded?: () => void;
|
||||||
|
userRole?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole }) => {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
||||||
|
const [role, setRole] = useState<'OWNER' | 'MANAGER' | 'MEMBER' | 'MEMBER_NO_FINANCE' | 'VIEWER_ONLY'>('MEMBER');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [fetchError, setFetchError] = useState('');
|
||||||
|
const [submitError, setSubmitError] = useState('');
|
||||||
|
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
||||||
|
const [confirmTarget, setConfirmTarget] = useState<{ userId: string; name: string } | null>(null);
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const participantIds = useMemo(() => new Set(participants.map((p) => p.userId)), [participants]);
|
||||||
|
const requestUserIds = useMemo(() => new Set(joinRequests.map((r) => (r as any).userId)), [joinRequests]);
|
||||||
|
const visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]);
|
||||||
|
|
||||||
|
const canCreateDirectly = !userRole || userRole === 'OWNER' || userRole === 'MANAGER';
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setFetchError('');
|
||||||
|
try {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/users?q=${encodeURIComponent(query)}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Không thể tải danh sách người dùng');
|
||||||
|
const data = await res.json();
|
||||||
|
setUsers(Array.isArray(data) ? data : []);
|
||||||
|
} catch (err: any) {
|
||||||
|
setFetchError(err.message || 'Không thể tải danh sách người dùng');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
fetchUsers();
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setQuery('');
|
||||||
|
setSelectedUser(null);
|
||||||
|
setRole('MEMBER');
|
||||||
|
setFetchError('');
|
||||||
|
setSubmitError('');
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleRemove = async (userId: string, memberName: string) => {
|
||||||
|
if (!onRemoveMember) return;
|
||||||
|
setConfirmTarget({ userId, name: memberName });
|
||||||
|
setIsConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmRemove = async () => {
|
||||||
|
if (!confirmTarget || !onRemoveMember) return;
|
||||||
|
try {
|
||||||
|
await onRemoveMember(confirmTarget.userId);
|
||||||
|
} catch (err: any) {
|
||||||
|
setSubmitError(err.message || 'Không thể xóa thành viên');
|
||||||
|
} finally {
|
||||||
|
setIsConfirmOpen(false);
|
||||||
|
setConfirmTarget(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRequestAction = async (reqId: string, action: 'accept' | 'reject', userName: string) => {
|
||||||
|
if (!onMemberAdded) return;
|
||||||
|
setActionLoading(reqId);
|
||||||
|
try {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const endpoint = action === 'accept'
|
||||||
|
? `${API_BASE}/api/v1/tours/${tourId}/join-requests/${reqId}/accept`
|
||||||
|
: `${API_BASE}/api/v1/tours/${tourId}/join-requests/${reqId}/reject`;
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.message || data.error || (action === 'accept' ? 'Không thể chấp nhận' : 'Không thể từ chối'));
|
||||||
|
}
|
||||||
|
await onMemberAdded();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Thao tác thất bại');
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!selectedUser) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setSubmitError('');
|
||||||
|
try {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const endpoint = canCreateDirectly ? `/api/v1/tours/${tourId}/members` : `/api/v1/tours/${tourId}/join-requests`;
|
||||||
|
const body = canCreateDirectly
|
||||||
|
? { userId: selectedUser, role }
|
||||||
|
: { userId: selectedUser };
|
||||||
|
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || data.error || 'Thao tác thất bại');
|
||||||
|
}
|
||||||
|
await onMemberAdded?.();
|
||||||
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
setSubmitError(err.message || 'Thao tác thất bại');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
|
||||||
|
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||||
|
<div className="p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<UserPlus className="w-5 h-5 text-blue-600" /> {canCreateDirectly ? 'Thêm thành viên' : 'Mời tham gia tour'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{canCreateDirectly ? 'Chọn người dùng và phân quyền cho tour này.' : 'Mời sẽ được gửi và chờ người quản lý phê duyệt.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
|
||||||
|
<X className="w-5 h-5 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-bold text-gray-500 mb-2">Thành viên của tour ({participants.length})</p>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{participants.map((p) => {
|
||||||
|
const rawToken = localStorage.getItem('token');
|
||||||
|
let currentUserId: string | null = null;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob((rawToken || '').split('.')[1]));
|
||||||
|
currentUserId = payload.sub;
|
||||||
|
} catch {
|
||||||
|
currentUserId = null;
|
||||||
|
}
|
||||||
|
const isCurrentUser = currentUserId && p.userId === currentUserId;
|
||||||
|
const isOwner = p.role === 'OWNER';
|
||||||
|
const canRemove = onRemoveMember && !isCurrentUser && !isOwner;
|
||||||
|
return (
|
||||||
|
<div key={p.userId} className="flex flex-col items-center gap-1">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden">
|
||||||
|
{p.user?.name?.charAt(0) || '?'}
|
||||||
|
</div>
|
||||||
|
{canRemove && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemove(p.userId, p.user?.name || p.userId)}
|
||||||
|
className="absolute -top-1 -right-1 w-4 h-4 bg-gray-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white"
|
||||||
|
aria-label="Remove item"
|
||||||
|
>
|
||||||
|
<Trash2 size={10} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{p.user?.name || p.userId}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{participants.length === 0 && (
|
||||||
|
<span className="text-xs text-gray-400">Chưa có thành viên nào</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{joinRequests.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-bold text-gray-500 mb-2 flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3 text-amber-500" /> Đang chờ phê duyệt ({joinRequests.length})
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{(joinRequests as any[]).map((req) => (
|
||||||
|
<div key={req.id} className="flex flex-col items-center gap-1 relative">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-600 font-bold text-sm overflow-hidden">
|
||||||
|
{req.user?.name?.charAt(0) || '?'}
|
||||||
|
</div>
|
||||||
|
<div className="absolute -top-1 -right-1 flex">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionLoading === req.id}
|
||||||
|
onClick={() => handleRequestAction(req.id, 'accept', req.user?.name || req.userId)}
|
||||||
|
className="w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50"
|
||||||
|
aria-label="Accept"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionLoading === req.id}
|
||||||
|
onClick={() => handleRequestAction(req.id, 'reject', req.user?.name || req.userId)}
|
||||||
|
className="w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1"
|
||||||
|
aria-label="Reject"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{req.user?.name || req.userId}</span>
|
||||||
|
<span className="text-[9px] font-bold text-amber-600 bg-amber-100 px-1.5 py-0.5 rounded-full border border-amber-200">PENDING</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canCreateDirectly && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold text-gray-700 ml-1">Phân quyền</label>
|
||||||
|
<select
|
||||||
|
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||||
|
value={role}
|
||||||
|
onChange={(e) => setRole(e.target.value as any)}
|
||||||
|
>
|
||||||
|
<option value="OWNER">OWNER</option>
|
||||||
|
<option value="MANAGER">MANAGER</option>
|
||||||
|
<option value="MEMBER">MEMBER</option>
|
||||||
|
<option value="MEMBER_NO_FINANCE">MEMBER_NO_FINANCE</option>
|
||||||
|
<option value="VIEWER_ONLY">VIEWER_ONLY</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{fetchError && (
|
||||||
|
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
|
||||||
|
{fetchError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{submitError && (
|
||||||
|
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
|
||||||
|
{submitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-10"><Loader2 className="w-8 h-8 animate-spin text-blue-600" /></div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-[40vh] overflow-y-auto pr-1">
|
||||||
|
{visibleUsers.map((u) => {
|
||||||
|
const isSelected = selectedUser === u.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={u.id}
|
||||||
|
onClick={() => setSelectedUser(u.id)}
|
||||||
|
disabled={requestUserIds.has(u.id)}
|
||||||
|
className={`w-full flex items-center gap-3 p-3 rounded-2xl border transition-all ${
|
||||||
|
isSelected ? 'border-blue-500 bg-blue-50/60' : 'border-gray-100 hover:border-blue-200'
|
||||||
|
} ${requestUserIds.has(u.id) ? 'opacity-60' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
|
||||||
|
{u.name?.charAt(0) || '?'}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<div className="text-sm font-bold text-gray-900">{u.name || 'Chưa đặt tên'}</div>
|
||||||
|
<div className="text-[11px] text-gray-500">{u.email}</div>
|
||||||
|
<div className="text-[11px] text-gray-400">{u.phone || ''} {u.address ? `• ${u.address}` : ''}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-[10px] font-bold text-gray-500">
|
||||||
|
{u.isAdmin ? (
|
||||||
|
<span className="px-2 py-1 bg-purple-50 text-purple-600 rounded-md border border-purple-100">ADMIN</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-1 bg-gray-50 text-gray-500 rounded-md border border-gray-100">USER</span>
|
||||||
|
)}
|
||||||
|
{isSelected && <Shield className="w-3 h-3 text-blue-600" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!loading && visibleUsers.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-sm text-gray-500">Không tìm thấy người dùng phù hợp</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 border-t border-gray-100 flex justify-end gap-2">
|
||||||
|
<button onClick={onClose} className="px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
|
||||||
|
Hủy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={!selectedUser || submitting}
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-xl text-sm font-bold transition-all"
|
||||||
|
>
|
||||||
|
{submitting ? 'Đang xử lý...' : canCreateDirectly ? 'Thêm vào tour' : 'Gửi lời mời'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isConfirmOpen && (
|
||||||
|
<div className="fixed inset-0 z-[2100] flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-gray-900/70 backdrop-blur-sm" onClick={() => setIsConfirmOpen(false)} />
|
||||||
|
<div className="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5">
|
||||||
|
<h3 className="text-base font-bold text-gray-900">Xác nhận xóa thành viên</h3>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
Bạn có chắc muốn xóa <span className="font-semibold text-gray-800">{confirmTarget?.name}</span> khỏi tour này?
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<button onClick={() => setIsConfirmOpen(false)} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
|
||||||
|
Hủy
|
||||||
|
</button>
|
||||||
|
<button onClick={confirmRemove} className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors">
|
||||||
|
Xóa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,19 +1,64 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { LandingPage } from './LandingPage';
|
import { LandingPage } from './LandingPage.js';
|
||||||
import { TourDetailPage } from './TourDetailPage';
|
import { TourDetailPage } from './TourDetailPage.js';
|
||||||
import { ExploreMap } from './ExploreMap';
|
import { ExploreMap } from './ExploreMap.js';
|
||||||
import { SignupPage } from './SignupPage';
|
import { SignupPage } from './SignupPage.js';
|
||||||
|
import { useTourStore } from './useTourStore.js';
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
type View = 'landing' | 'explore' | 'detail' | 'signup';
|
type View = 'landing' | 'explore' | 'detail' | 'signup';
|
||||||
const [view, setView] = useState<View>('landing');
|
const [view, setView] = useState<View>('landing');
|
||||||
|
const [isInitialSetup, setIsInitialSetup] = useState(false);
|
||||||
|
const [user, setUser] = useState<any>(null);
|
||||||
|
const [isUserLoaded, setIsUserLoaded] = useState(false); // Trạng thái để biết user đã được load từ localStorage chưa
|
||||||
|
const fetchTour = useTourStore(state => state.fetchTour);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Tự động xác định địa chỉ IP của Backend dựa trên hostname hiện tại
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
|
||||||
|
// Khôi phục phiên đăng nhập từ localStorage
|
||||||
|
const savedUser = localStorage.getItem('user');
|
||||||
|
if (savedUser) {
|
||||||
|
const parsedUser = JSON.parse(savedUser);
|
||||||
|
setUser(parsedUser);
|
||||||
|
}
|
||||||
|
setIsUserLoaded(true); // Đánh dấu user đã được load
|
||||||
|
|
||||||
|
// Kiểm tra xem hệ thống đã được cài đặt chưa
|
||||||
|
fetch(`${API_BASE}/api/v1/auth/status`)
|
||||||
|
.then(res => res.ok ? res.json() : Promise.reject())
|
||||||
|
.then(data => setIsInitialSetup(!!data.isInitialSetup))
|
||||||
|
.catch(() => setIsInitialSetup(false));
|
||||||
|
}, []); // Chạy một lần khi component mount
|
||||||
|
|
||||||
|
// Effect để xử lý chuyển hướng nếu user đã đăng nhập và đang ở trang landing
|
||||||
|
useEffect(() => {
|
||||||
|
if (isUserLoaded && user && view === 'landing') {
|
||||||
|
setView('explore');
|
||||||
|
}
|
||||||
|
}, [isUserLoaded, user, view]);
|
||||||
|
|
||||||
|
const handleLoginSuccess = (userData: any) => {
|
||||||
|
setUser(userData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
setUser(null);
|
||||||
|
setView('landing');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
{view === 'landing' && (
|
{view === 'landing' && (
|
||||||
<LandingPage
|
<LandingPage
|
||||||
|
isInitialSetup={isInitialSetup}
|
||||||
onContinue={() => setView('explore')}
|
onContinue={() => setView('explore')}
|
||||||
onGoToSignup={() => setView('signup')}
|
onGoToSignup={() => setView('signup')}
|
||||||
|
onGoToMap={() => setView('explore')}
|
||||||
|
onLoginSuccess={handleLoginSuccess}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -25,11 +70,19 @@ const App = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{view === 'explore' && (
|
{view === 'explore' && (
|
||||||
<ExploreMap onBack={() => setView('landing')} />
|
<ExploreMap
|
||||||
|
onBack={() => setView('landing')}
|
||||||
|
onLogout={user ? handleLogout : undefined}
|
||||||
|
user={user}
|
||||||
|
onViewTour={(id) => {
|
||||||
|
fetchTour(id);
|
||||||
|
setView('detail');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{view === 'detail' && (
|
{view === 'detail' && (
|
||||||
<TourDetailPage />
|
<TourDetailPage onBack={() => setView('explore')} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ConfirmModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfirmModal: React.FC<ConfirmModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
title = 'Xác nhận',
|
||||||
|
message,
|
||||||
|
confirmText = 'Xác nhận',
|
||||||
|
cancelText = 'Hủy',
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[2200] flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-gray-900/70 backdrop-blur-sm" onClick={onCancel} />
|
||||||
|
<div className="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5">
|
||||||
|
<h3 className="text-base font-bold text-gray-900">{title}</h3>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">{message}</p>
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<button onClick={onCancel} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button onClick={onConfirm} className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors">
|
||||||
|
{confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
import { useTourStore } from './useTourStore.js';
|
||||||
|
|
||||||
|
export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolean, onClose: () => void, onSuccess: (tour: any) => void }) => {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [endDate, setEndDate] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const createTour = useTourStore((state) => state.createTour);
|
||||||
|
|
||||||
|
const [members, setMembers] = useState<any[]>([]);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [results, setResults] = useState<any[]>([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const searchUsers = async (value: string) => {
|
||||||
|
setQuery(value);
|
||||||
|
if (!value.trim()) {
|
||||||
|
setResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/users?q=${encodeURIComponent(value)}`, {
|
||||||
|
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Không thể tải người dùng');
|
||||||
|
const data = await res.json();
|
||||||
|
setResults(Array.isArray(data) ? data : []);
|
||||||
|
} catch (e: any) {
|
||||||
|
setResults([]);
|
||||||
|
setError(e.message || 'Không thể tải người dùng');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmAddMember = (user: any) => {
|
||||||
|
setMembers((prev) => (prev.some((m) => m.id === user.id) ? prev : [...prev, user]));
|
||||||
|
setQuery('');
|
||||||
|
setResults([]);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMember = (userId: string) => {
|
||||||
|
setMembers((prev) => prev.filter((m) => m.id !== userId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const memberIds = members.map((m) => m.id);
|
||||||
|
const tour = await createTour({ title, startDate, endDate, memberIds });
|
||||||
|
onSuccess(tour);
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || 'Lỗi khi tạo tour');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
|
||||||
|
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden p-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">Tạo Tour mới</h2>
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Tên Tour</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="VD: Khám phá Đà Lạt"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Bắt đầu</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Kết thúc</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-2">Thành viên tham gia</label>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{members.map((m) => (
|
||||||
|
<div key={m.id} className="relative">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden">
|
||||||
|
{m.name}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeMember(m.id)}
|
||||||
|
className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-gray-500 hover:bg-red-600 text-white flex items-center justify-center transition-colors"
|
||||||
|
aria-label="Remove item"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
<div className="text-[10px] text-center mt-1 max-w-[70px] truncate">{m.name}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
className="w-36 px-2 py-1 text-sm border border-gray-200 rounded-lg outline-none"
|
||||||
|
placeholder="Tìm email..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => searchUsers(e.target.value)}
|
||||||
|
/>
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-2 bg-white border border-gray-100 rounded-xl shadow-lg z-10 max-h-60 overflow-y-auto">
|
||||||
|
{results.map((u) => (
|
||||||
|
<button
|
||||||
|
key={u.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => confirmAddMember(u)}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
<span className="font-bold text-gray-900">{u.name}</span>
|
||||||
|
<span className="block text-xs text-gray-500">{u.email}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-red-600 mt-2">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl shadow-lg transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Đang tạo...' : 'Xác nhận tạo Tour'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { Wallet, Users, Info } from 'lucide-react';
|
import { Wallet, Users, Info } from 'lucide-react';
|
||||||
import { useTourStore } from './useTourStore';
|
import { useTourStore } from './useTourStore.js';
|
||||||
|
|
||||||
export const ExpenseManager = () => {
|
export const ExpenseManager = () => {
|
||||||
const { legs } = useTourStore();
|
const { legs } = useTourStore();
|
||||||
|
|||||||
+137
-26
@@ -1,9 +1,13 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents } from 'react-leaflet';
|
||||||
|
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||||
|
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { useTourStore } from './useTourStore';
|
import { useTourStore } from './useTourStore.js';
|
||||||
import { X, Navigation, Image as ImageIcon } from 'lucide-react';
|
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2 } from 'lucide-react';
|
||||||
|
import { UserManagementModal } from './UserManagementModal.js';
|
||||||
|
import { CreateTourModal } from './CreateTourModal.js';
|
||||||
|
|
||||||
// Fix lỗi icon mặc định của Leaflet
|
// Fix lỗi icon mặc định của Leaflet
|
||||||
const DefaultIcon = L.icon({
|
const DefaultIcon = L.icon({
|
||||||
@@ -14,16 +18,68 @@ const DefaultIcon = L.icon({
|
|||||||
});
|
});
|
||||||
L.Marker.prototype.options.icon = DefaultIcon;
|
L.Marker.prototype.options.icon = DefaultIcon;
|
||||||
|
|
||||||
export const ExploreMap = ({ onBack }: { onBack: () => void }) => {
|
// Component Helper để cập nhật tâm bản đồ khi vị trí người dùng thay đổi
|
||||||
const { publicTours, fetchPublicTours } = useTourStore();
|
function RecenterMap({ position }: { position: [number, number] }) {
|
||||||
const [userPos, setUserPos] = useState<[number, number]>([10.7769, 106.7009]); // Mặc định TP.HCM
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
map.setView(position, map.getZoom());
|
||||||
|
}, [position, map]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component Helper để theo dõi sự di chuyển của người dùng trên bản đồ
|
||||||
|
function MapTracker() {
|
||||||
|
const setMapCenter = useTourStore(state => state.setMapCenter);
|
||||||
|
useMapEvents({
|
||||||
|
moveend: (e) => {
|
||||||
|
const map = e.target;
|
||||||
|
const center = map.getCenter();
|
||||||
|
const zoom = map.getZoom();
|
||||||
|
const coords: [number, number] = [center.lat, center.lng];
|
||||||
|
|
||||||
|
setMapCenter(coords);
|
||||||
|
// Lưu vị trí và mức zoom vào localStorage để sử dụng cho lần sau
|
||||||
|
localStorage.setItem('map_view_state', JSON.stringify({ center: coords, zoom }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void }) => {
|
||||||
|
// Thêm fetchTour vào destructuring từ store
|
||||||
|
const { publicTours, fetchPublicTours, fetchTour, setMapCenter } = useTourStore();
|
||||||
|
|
||||||
|
// Khởi tạo vị trí từ localStorage nếu có, nếu không dùng mặc định (TP.HCM)
|
||||||
|
const [initialViewState] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('map_view_state');
|
||||||
|
if (saved) {
|
||||||
|
try { return JSON.parse(saved); } catch (e) { return null; }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [userPos, setUserPos] = useState<[number, number]>(initialViewState?.center || [10.7769, 106.7009]);
|
||||||
|
const [mapZoom] = useState(initialViewState?.zoom || 13);
|
||||||
|
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPublicTours();
|
fetchPublicTours();
|
||||||
|
|
||||||
|
// Nếu không có vị trí lưu từ trước, mới yêu cầu lấy vị trí hiện tại của thiết bị
|
||||||
|
if (!initialViewState) {
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(pos) => setUserPos([pos.coords.latitude, pos.coords.longitude]),
|
(pos) => {
|
||||||
|
const posArray: [number, number] = [pos.coords.latitude, pos.coords.longitude];
|
||||||
|
setUserPos(posArray);
|
||||||
|
setMapCenter(posArray);
|
||||||
|
},
|
||||||
() => console.log("Không thể lấy vị trí người dùng")
|
() => console.log("Không thể lấy vị trí người dùng")
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// Cập nhật store để đồng bộ với vị trí khởi tạo từ cache
|
||||||
|
setMapCenter(initialViewState.center);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,6 +92,39 @@ export const ExploreMap = ({ onBack }: { onBack: () => void }) => {
|
|||||||
<X className="w-6 h-6 text-gray-800" />
|
<X className="w-6 h-6 text-gray-800" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Nút đăng xuất - Chỉ hiển thị khi có user login */}
|
||||||
|
{onLogout && (
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
className="absolute top-6 right-6 z-[1000] bg-white px-4 py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center gap-2 font-bold text-gray-700"
|
||||||
|
>
|
||||||
|
<LogOut className="w-5 h-5" />
|
||||||
|
<span className="hidden sm:inline">Đăng xuất</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Nút quản lý người dùng cho Admin */}
|
||||||
|
{user?.isAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAdminModalOpen(true)}
|
||||||
|
className="absolute top-6 right-40 z-[1000] bg-blue-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center gap-2 font-bold"
|
||||||
|
>
|
||||||
|
<Settings className="w-5 h-5" />
|
||||||
|
<span className="hidden sm:inline">Quản lý hệ thống</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Nút tạo Tour mới */}
|
||||||
|
{user && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
|
className="absolute top-6 right-80 z-[1000] bg-green-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center gap-2 font-bold"
|
||||||
|
>
|
||||||
|
<Navigation className="w-5 h-5" />
|
||||||
|
<span className="hidden sm:inline">Tạo Tour mới</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header Overlay */}
|
{/* Header Overlay */}
|
||||||
<div className="absolute top-6 left-20 z-[1000] bg-white/90 backdrop-blur-md px-6 py-3 rounded-2xl shadow-xl border border-white/20 hidden sm:block">
|
<div className="absolute top-6 left-20 z-[1000] bg-white/90 backdrop-blur-md px-6 py-3 rounded-2xl shadow-xl border border-white/20 hidden sm:block">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -44,22 +133,38 @@ export const ExploreMap = ({ onBack }: { onBack: () => void }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MapContainer center={userPos} zoom={13} className="h-full w-full">
|
<MapContainer
|
||||||
|
center={userPos}
|
||||||
|
zoom={mapZoom}
|
||||||
|
className="h-full w-full"
|
||||||
|
preferCanvas={true}
|
||||||
|
>
|
||||||
<TileLayer
|
<TileLayer
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
attribution='© OpenStreetMap contributors'
|
attribution='© OpenStreetMap contributors'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{publicTours.map((tour) => {
|
{/* Theo dõi di chuyển bản đồ */}
|
||||||
const place = tour.legs?.[0]?.places?.[0];
|
<MapTracker />
|
||||||
if (!place) return null;
|
|
||||||
|
|
||||||
|
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
|
||||||
|
<RecenterMap position={userPos} />
|
||||||
|
|
||||||
|
<MarkerClusterGroup chunkedLoading>
|
||||||
|
{publicTours.map((tour) => {
|
||||||
|
const startLoc = tour.legs?.[0]?.locations?.[0];
|
||||||
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
|
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
|
||||||
|
const markerPos = startLoc
|
||||||
|
? [startLoc.latitude, startLoc.longitude] as [number, number]
|
||||||
|
: userPos;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<React.Fragment key={tour.id}>
|
||||||
<Marker
|
<Marker
|
||||||
key={tour.id}
|
position={markerPos}
|
||||||
position={[place.latitude, place.longitude]}
|
eventHandlers={{
|
||||||
|
click: () => onViewTour(tour.id)
|
||||||
|
}}
|
||||||
icon={L.divIcon({
|
icon={L.divIcon({
|
||||||
className: 'custom-bubble',
|
className: 'custom-bubble',
|
||||||
html: `
|
html: `
|
||||||
@@ -67,27 +172,33 @@ export const ExploreMap = ({ onBack }: { onBack: () => void }) => {
|
|||||||
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
|
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
|
||||||
<img src="${tourImage}" class="w-full h-full object-cover" />
|
<img src="${tourImage}" class="w-full h-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full p-1 border-2 border-white">
|
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
|
S
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
iconSize: [48, 48],
|
iconSize: [48, 48],
|
||||||
|
iconAnchor: [24, 24]
|
||||||
})}
|
})}
|
||||||
>
|
/>
|
||||||
<Popup className="custom-popup">
|
</React.Fragment>
|
||||||
<div className="p-1 max-w-[200px]">
|
|
||||||
<img src={tourImage} className="w-full h-32 object-cover rounded-lg mb-2" />
|
|
||||||
<h4 className="font-bold text-gray-900 leading-tight mb-1">{tour.title}</h4>
|
|
||||||
<button className="text-xs font-bold text-blue-600 flex items-center gap-1">
|
|
||||||
Xem chi tiết hình ảnh <ImageIcon className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Popup>
|
|
||||||
</Marker>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</MarkerClusterGroup>
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
|
|
||||||
|
{/* Admin Modal */}
|
||||||
|
<UserManagementModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} />
|
||||||
|
|
||||||
|
{/* Create Tour Modal */}
|
||||||
|
<CreateTourModal
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
|
onSuccess={(tour) => {
|
||||||
|
fetchTour(tour.id);
|
||||||
|
onViewTour(tour.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+297
-41
@@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
||||||
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle } from 'lucide-react';
|
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List } from 'lucide-react';
|
||||||
import { useTourStore } from './useTourStore';
|
import { useTourStore } from './useTourStore.js';
|
||||||
|
import { ConfirmModal } from './ConfirmModal.js';
|
||||||
|
|
||||||
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
|
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
|
||||||
if (!actual) return null;
|
if (!actual) return null;
|
||||||
@@ -19,42 +20,212 @@ const TimeVariance = ({ planned, actual }: { planned: string, actual: string | n
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ItineraryTimeline = () => {
|
const calculateDistance = (lat1: number, lon1: number, lat2: number, lon2: number) => {
|
||||||
const { legs } = useTourStore();
|
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 toggleComplete = async (placeId: number) => {
|
const formatTravelTime = (minutes: number) => {
|
||||||
// Gọi API PATCH /api/v1/places/:id để cập nhật trạng thái
|
if (minutes < 60) return `${minutes} phút`;
|
||||||
console.log("Toggle status for place:", placeId);
|
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
|
||||||
|
}: { onAddLocation?: (legId: string) => void, onEditLocation?: (location: any) => void }) => {
|
||||||
|
const { currentTour, legs, optimizeRouting, userRole, addLeg, updateLeg, deleteLeg, initializeLegs, deleteLocation } = useTourStore();
|
||||||
|
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
|
||||||
|
|
||||||
|
const toggleComplete = async (locationId: string) => {
|
||||||
|
// Gọi API PATCH /api/v1/locations/:id để cập nhật trạng thái
|
||||||
|
console.log("Toggle status for location:", locationId);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
const countStr = window.prompt("Chuyến đi này bạn muốn chia làm bao nhiêu chặng?", "3");
|
||||||
|
const count = parseInt(countStr || "0");
|
||||||
|
if (count > 0 && currentTour) {
|
||||||
|
await initializeLegs(currentTour.id, count);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditLeg = async (leg: any) => {
|
||||||
|
const note = window.prompt("Sửa ghi chú chặng:", leg.note || "");
|
||||||
|
if (note !== null) {
|
||||||
|
await updateLeg(leg.id, { note });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteLeg = async (legId: string) => {
|
||||||
|
setConfirmState({
|
||||||
|
open: true,
|
||||||
|
title: 'Xóa chặng',
|
||||||
|
message: 'Bạn có chắc chắn muốn xóa chặng này?',
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await deleteLeg(legId);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message);
|
||||||
|
} finally {
|
||||||
|
setConfirmState({ open: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteLocation = async (id: string) => {
|
||||||
|
setConfirmState({
|
||||||
|
open: true,
|
||||||
|
title: 'Xóa địa điểm',
|
||||||
|
message: 'Bạn có chắc chắn muốn xóa địa điểm này?',
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await deleteLocation(id);
|
||||||
|
} catch (err: any) { alert(err.message); } finally {
|
||||||
|
setConfirmState({ open: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto p-4 sm:p-6 bg-gray-50 min-h-screen">
|
<div className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen">
|
||||||
<h2 className="text-2xl font-bold text-gray-800 mb-8 px-2">Lộ trình chuyến đi</h2>
|
<div className="px-2 pt-4">
|
||||||
|
{legs.length === 0 ? (
|
||||||
<div className="space-y-8">
|
<div className="text-center py-20 bg-white rounded-3xl border-2 border-dashed border-gray-200">
|
||||||
{legs.map((leg, legIdx) => (
|
<List className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||||
<div key={leg.id} className="relative">
|
<p className="text-gray-500 font-medium">Chưa có chặng nào trong lộ trình.</p>
|
||||||
{/* Leg Header */}
|
|
||||||
<div className="flex items-center mb-4 px-2">
|
|
||||||
<div className="bg-blue-600 text-white text-sm font-bold px-3 py-1 rounded-full shadow-sm">
|
|
||||||
Chặng {leg.sequenceNumber}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4 h-[1px] flex-1 bg-gray-200" />
|
) : (
|
||||||
|
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 (
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
{leg.sequence}
|
||||||
|
</span>
|
||||||
|
{leg.note || `Chi tiết Chặng ${leg.sequence}`}
|
||||||
|
</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>
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
{['OWNER', 'MANAGER'].includes(userRole || '') && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => onAddLocation?.(leg.id)}
|
||||||
|
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)}
|
||||||
|
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)}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{leg.totalDistance !== undefined && (
|
||||||
|
<div className="hidden sm: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="hidden md:flex text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
Dừng: {formatTravelTime(totalDwellMinutes)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{['OWNER', 'MANAGER'].includes(userRole || '') && legs.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => optimizeRouting(leg.id)}
|
||||||
|
className="ml-auto flex items-center gap-1.5 text-xs font-bold text-indigo-600 hover:text-indigo-700 bg-indigo-50 hover:bg-indigo-100 px-3 py-1.5 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Zap className="w-3 h-3" />
|
||||||
|
Tối ưu
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vertical Line for the whole leg */}
|
{/* Vertical Line for the whole leg */}
|
||||||
<div className="absolute left-6 top-10 bottom-0 w-0.5 bg-gray-200 -z-0" />
|
{/* 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`} />
|
||||||
|
|
||||||
<div className="space-y-6 ml-2">
|
<div className="ml-2">
|
||||||
{leg.places.map((place) => (
|
{leg.locations.map((location, idx) => {
|
||||||
<div key={place.id} className="relative flex group">
|
// Logic quan trọng: Gán điểm cuối chặng này nối với điểm đầu chặng sau
|
||||||
|
const nextLocation = leg.locations[idx + 1] || legs[legIdx + 1]?.locations[0];
|
||||||
|
const distanceToNext = nextLocation
|
||||||
|
? calculateDistance(location.latitude, location.longitude, nextLocation.latitude, nextLocation.longitude)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const averageSpeed = 35; // km/h
|
||||||
|
const travelTimeMinutes = distanceToNext ? Math.round((distanceToNext / averageSpeed) * 60) : null;
|
||||||
|
|
||||||
|
const dwellMinutes = (location.plannedStart && location.plannedEnd)
|
||||||
|
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const locationExpense = leg.expenses?.find((e: any) => e.locationId === location.id);
|
||||||
|
|
||||||
|
const isStartPoint = legs[0]?.id === leg.id && idx === 0;
|
||||||
|
const isEndPoint = legs[legs.length - 1]?.id === leg.id && idx === leg.locations.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={location.id}>
|
||||||
|
<div className="relative flex group mb-6">
|
||||||
{/* Timeline Node */}
|
{/* Timeline Node */}
|
||||||
<div className="z-10 mt-1.5 mr-4">
|
<div className="z-10 mt-1.5 mr-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleComplete(place.id)}
|
onClick={() => toggleComplete(location.id)}
|
||||||
className={`transition-colors duration-200 ${place.isCompleted ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`}
|
className={`transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`}
|
||||||
>
|
>
|
||||||
{place.isCompleted ? (
|
{location.status === 'COMPLETED' ? (
|
||||||
<CheckCircle2 className="w-8 h-8 bg-white rounded-full" />
|
<CheckCircle2 className="w-8 h-8 bg-white rounded-full" />
|
||||||
) : (
|
) : (
|
||||||
<Circle className="w-8 h-8 bg-white rounded-full fill-white" />
|
<Circle className="w-8 h-8 bg-white rounded-full fill-white" />
|
||||||
@@ -64,47 +235,132 @@ export const ItineraryTimeline = () => {
|
|||||||
|
|
||||||
{/* Card Content */}
|
{/* Card Content */}
|
||||||
<div className={`flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${
|
<div className={`flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${
|
||||||
place.isCompleted ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'
|
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 className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h3 className={`font-semibold text-lg ${place.isCompleted ? 'text-gray-500 line-through' : 'text-gray-800'}`}>
|
{isStartPoint && (
|
||||||
{place.name}
|
<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 className={`font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`}>
|
||||||
|
{location.name}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center text-sm text-gray-500 mt-1">
|
<div className="flex items-center text-sm text-gray-500 mt-1">
|
||||||
<MapPin className="w-3 h-3 mr-1" />
|
<MapPin className="w-3 h-3 mr-1" />
|
||||||
<span className="truncate max-w-[200px] sm:max-w-md">{place.address}</span>
|
<span className="truncate max-w-[200px] sm:max-w-md">{location.address}</span>
|
||||||
</div>
|
</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()}đ ({locationExpense.category})</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>
|
||||||
|
|
||||||
<div className="text-right flex flex-col items-end">
|
<div className="text-right flex flex-col items-end">
|
||||||
<div className="flex items-center text-sm font-medium text-blue-600">
|
<div className="flex items-center text-sm font-medium text-blue-600">
|
||||||
<Clock className="w-3 h-3 mr-1" />
|
<Clock className="w-3 h-3 mr-1" />
|
||||||
{format(parseISO(place.arrivalTime), 'HH:mm')}
|
{location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'}
|
||||||
</div>
|
</div>
|
||||||
{place.isCompleted && place.completedAt && (
|
{location.status === 'COMPLETED' && location.actualStart && (
|
||||||
<div className="text-[10px] text-gray-400 mt-1 italic">
|
<div className="text-[10px] text-gray-400 mt-1 italic">
|
||||||
Thực tế: {format(parseISO(place.completedAt), 'HH:mm')}
|
Thực tế: {format(parseISO(location.actualStart), 'HH:mm')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{['OWNER', 'MANAGER'].includes(userRole || '') && !isStartPoint && !isEndPoint && (
|
||||||
|
<div className="flex gap-1 mt-2">
|
||||||
|
<button onClick={() => onEditLocation?.(location)} className="p-1 text-gray-400 hover:text-blue-600 transition-colors">
|
||||||
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDeleteLocation(location.id)} className="p-1 text-gray-400 hover:text-red-600 transition-colors">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logic tính toán độ lệch thời gian */}
|
{/* Logic tính toán độ lệch thời gian */}
|
||||||
<TimeVariance planned={place.arrivalTime} actual={place.completedAt} />
|
<TimeVariance planned={location.plannedStart || ''} actual={location.actualStart || null} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{leg.notes && (
|
{distanceToNext !== null && travelTimeMinutes !== null && (
|
||||||
<p className="ml-14 mt-4 text-sm text-gray-400 italic">
|
<div className="ml-4 -mt-4 mb-2 flex items-center gap-3">
|
||||||
* {leg.notes}
|
<div className="w-8 flex justify-center">
|
||||||
</p>
|
<Navigation className="w-3 h-3 text-blue-400 rotate-180" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[10px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100">
|
||||||
|
{distanceToNext.toFixed(2)} km
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-bold text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full border border-gray-100 flex items-center gap-1">
|
||||||
|
<Clock className="w-2.5 h-2.5" />
|
||||||
|
~ {formatTravelTime(travelTimeMinutes)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions at the bottom of the list */}
|
||||||
|
{['OWNER', 'MANAGER'].includes(userRole || '') && (
|
||||||
|
<div className="flex flex-col gap-3 pb-20 mt-8">
|
||||||
|
<button
|
||||||
|
onClick={handleDeclareLegs}
|
||||||
|
className="w-full py-4 rounded-2xl bg-white text-blue-600 border-2 border-dashed border-blue-200 hover:bg-blue-50 transition-all flex items-center justify-center gap-2 text-sm font-bold"
|
||||||
|
>
|
||||||
|
<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 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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={confirmState.open}
|
||||||
|
title={confirmState.title}
|
||||||
|
message={confirmState.message}
|
||||||
|
onConfirm={() => confirmState.onConfirm?.()}
|
||||||
|
onCancel={() => setConfirmState({ open: false })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
+110
-40
@@ -1,69 +1,138 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { LogIn, Compass, ArrowRight, Camera } from 'lucide-react';
|
import { LogIn, Compass, ArrowRight, Map as MapIcon, UserPlus, ShieldCheck } from 'lucide-react';
|
||||||
import { LoginModal } from './LoginModal';
|
import { LoginModal } from './LoginModal.js';
|
||||||
|
|
||||||
|
const TRAVEL_IMAGES = [
|
||||||
|
"https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?auto=format&fit=crop&q=80",
|
||||||
|
"https://images.unsplash.com/photo-1503220317375-aaad61436b1b?auto=format&fit=crop&q=80",
|
||||||
|
"https://images.unsplash.com/photo-1513581166391-887a96df91e7?auto=format&fit=crop&q=80",
|
||||||
|
"https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&q=80",
|
||||||
|
"https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?auto=format&fit=crop&q=80",
|
||||||
|
"https://images.unsplash.com/photo-1530789253388-582c481c54b0?auto=format&fit=crop&q=80"
|
||||||
|
];
|
||||||
|
|
||||||
interface LandingPageProps {
|
interface LandingPageProps {
|
||||||
onContinue?: () => void;
|
onContinue?: () => void;
|
||||||
onGoToSignup?: () => void;
|
onGoToSignup?: () => void;
|
||||||
|
onGoToMap?: () => void;
|
||||||
|
onLoginSuccess?: (user: any) => void;
|
||||||
|
isInitialSetup?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSignup }) => {
|
export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSignup, onGoToMap, onLoginSuccess, isInitialSetup }) => {
|
||||||
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
// Chọn ngẫu nhiên một hình ảnh khi người dùng truy cập trang
|
||||||
<div className="flex h-screen w-full flex-col md:flex-row overflow-hidden font-sans relative">
|
const backgroundImage = useMemo(() => {
|
||||||
{/* Nửa bên trái: Ảnh background mặc định của dự án */}
|
return TRAVEL_IMAGES[Math.floor(Math.random() * TRAVEL_IMAGES.length)];
|
||||||
<div className="hidden md:block md:w-1/2 relative bg-gray-900">
|
}, []);
|
||||||
<img
|
|
||||||
src="https://images.unsplash.com/photo-1503220317375-aaad61436b1b?q=80&w=2070&auto=format&fit=crop"
|
|
||||||
alt="Travel Planner Background"
|
|
||||||
className="absolute inset-0 h-full w-full object-cover opacity-70"
|
|
||||||
/>
|
|
||||||
{/* Overlay màu xanh nhẹ đặc trưng của dự án */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-blue-900/60 to-transparent" />
|
|
||||||
|
|
||||||
<div className="absolute bottom-16 left-16 text-white z-10">
|
return (
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex h-screen w-full flex-col md:flex-row overflow-hidden font-sans bg-gray-50 relative">
|
||||||
<div className="p-3 bg-white/20 backdrop-blur-md rounded-2xl">
|
{/* Logo/Brand Header ở góc trên bên trái */}
|
||||||
<Compass className="w-8 h-8 text-white" />
|
<div className="absolute top-8 left-8 z-20 flex items-center gap-3 text-white md:text-white drop-shadow-lg">
|
||||||
|
<Compass className="w-10 h-10" />
|
||||||
|
<span className="text-2xl font-black tracking-tighter uppercase">Travel Planner</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-3xl font-black tracking-tighter uppercase">Travel Planner</span>
|
|
||||||
|
{/* Nửa bên trái: Hiển thị một hình ảnh duy nhất */}
|
||||||
|
<div className="hidden md:block md:w-1/2 relative h-full bg-blue-900">
|
||||||
|
<img
|
||||||
|
src={backgroundImage}
|
||||||
|
className="w-full h-full object-cover opacity-70"
|
||||||
|
alt="Travel Background"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-black/40 to-transparent pointer-events-none" />
|
||||||
|
|
||||||
|
{/* Thumbnail Grid ở góc dưới bên trái */}
|
||||||
|
<div className="absolute bottom-12 left-8 z-20 flex flex-col gap-2 items-start drop-shadow-2xl">
|
||||||
|
{/* Hàng trên cùng: 1 thumbnail */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<img src={TRAVEL_IMAGES[0]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hàng thứ 2: 2 thumbnail */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<img src={TRAVEL_IMAGES[1]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 2" />
|
||||||
|
<img src={TRAVEL_IMAGES[2]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 3" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hàng dưới cùng: 3 thumbnail */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<img src={TRAVEL_IMAGES[3]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 4" />
|
||||||
|
<img src={TRAVEL_IMAGES[4]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 5" />
|
||||||
|
<img src={TRAVEL_IMAGES[5]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 6" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-5xl font-bold leading-tight max-w-lg">
|
|
||||||
Khám phá thế giới, lưu giữ kỉ niệm.
|
|
||||||
</h2>
|
|
||||||
<p className="mt-4 text-blue-100 text-lg opacity-80 max-w-sm">
|
|
||||||
Hợp tác cùng bạn bè để lên lịch trình hoàn hảo nhất cho mọi chuyến đi.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nửa bên phải: Nội dung chào mừng và Hành động */}
|
{/* Nửa bên phải: Nội dung chào mừng và Hành động */}
|
||||||
<div className="flex-1 flex flex-col justify-center items-center bg-white p-8 sm:p-12 md:p-20">
|
<div className="flex-1 md:w-1/2 flex flex-col justify-center items-center bg-white p-8 lg:p-16 h-full overflow-y-auto">
|
||||||
<div className="max-w-md w-full space-y-12">
|
<div className="max-w-md w-full space-y-12">
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<h1 className="text-4xl md:text-5xl font-extrabold text-gray-900 leading-tight">
|
{isInitialSetup ? (
|
||||||
Chào mừng bạn đến với trang tạo kế hoạch du lịch
|
<>
|
||||||
|
<div className="inline-flex items-center px-3 py-1 rounded-full bg-amber-50 text-amber-700 text-xs font-bold border border-amber-100 mb-2">
|
||||||
|
<ShieldCheck className="w-3 h-3 mr-1" /> CẤU HÌNH HỆ THỐNG LẦN ĐẦU
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-extrabold text-gray-900 leading-tight">
|
||||||
|
Thiết lập Quản trị viên
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-gray-600 leading-relaxed">
|
<p className="text-lg text-gray-500 leading-relaxed">
|
||||||
Đăng nhập để vào tạo kế hoạch cá nhân hoặc nhấn nút tiếp tục để tham quan các hình ảnh du lịch được chia sẻ.
|
Chào mừng! Hệ thống vừa được cài đặt. Vui lòng tạo tài khoản đầu tiên để quản lý và vận hành trang web.
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h1 className="text-4xl font-extrabold text-gray-900 leading-tight">
|
||||||
|
Lên kế hoạch cho hành trình tiếp theo
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-gray-500 leading-relaxed">
|
||||||
|
Khám phá các địa điểm nổi bật qua bản đồ cộng đồng hoặc đăng nhập để bắt đầu tự tạo chuyến đi cho riêng mình.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
{/* Nút Đăng nhập */}
|
{isInitialSetup && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsLoginModalOpen(true)}
|
onClick={onGoToSignup}
|
||||||
className="group flex items-center justify-center gap-3 bg-blue-600 hover:bg-blue-700 text-white font-bold py-5 px-8 rounded-2xl shadow-xl shadow-blue-200 transition-all active:scale-95"
|
className="flex items-center justify-center gap-3 bg-blue-600 hover:bg-blue-700 text-white font-bold py-5 px-8 rounded-2xl transition-all shadow-xl shadow-blue-100 animate-bounce-subtle mb-2"
|
||||||
>
|
>
|
||||||
<LogIn className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
<UserPlus className="w-5 h-5" />
|
||||||
Đăng nhập ngay
|
Bắt đầu thiết lập ngay
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onGoToMap}
|
||||||
|
className="flex items-center justify-center gap-3 bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-4 px-8 rounded-2xl transition-all shadow-lg shadow-indigo-100"
|
||||||
|
>
|
||||||
|
<MapIcon className="w-5 h-5" />
|
||||||
|
Khám phá các hành trình du lịch
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Nút Tiếp tục */}
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsLoginModalOpen(true)}
|
||||||
|
className="flex items-center justify-center gap-2 bg-blue-50 hover:bg-blue-100 text-blue-700 font-bold py-4 rounded-2xl transition-all"
|
||||||
|
>
|
||||||
|
<LogIn className="w-5 h-5" />
|
||||||
|
Đăng nhập
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onGoToSignup}
|
||||||
|
className="flex items-center justify-center gap-2 bg-gray-50 hover:bg-gray-100 text-gray-700 font-bold py-4 rounded-2xl border border-gray-100 transition-all"
|
||||||
|
>
|
||||||
|
<UserPlus className="w-5 h-5" />
|
||||||
|
Đăng ký
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onContinue}
|
onClick={onContinue}
|
||||||
className="flex items-center justify-center gap-3 bg-gray-50 hover:bg-gray-100 text-gray-700 font-bold py-5 px-8 rounded-2xl border border-gray-200 transition-all active:scale-95"
|
className="flex items-center justify-center gap-3 text-gray-400 hover:text-gray-600 py-2 transition-all text-sm font-medium"
|
||||||
>
|
>
|
||||||
Tiếp tục tham quan ảnh
|
Tiếp tục tham quan ảnh
|
||||||
<ArrowRight className="w-5 h-5" />
|
<ArrowRight className="w-5 h-5" />
|
||||||
@@ -86,6 +155,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
|
|||||||
isOpen={isLoginModalOpen}
|
isOpen={isLoginModalOpen}
|
||||||
onClose={() => setIsLoginModalOpen(false)}
|
onClose={() => setIsLoginModalOpen(false)}
|
||||||
onSwitchToSignup={onGoToSignup}
|
onSwitchToSignup={onGoToSignup}
|
||||||
|
onLoginSuccess={onLoginSuccess}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+59
-7
@@ -1,15 +1,54 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { X, Mail, Lock, ArrowRight } from 'lucide-react';
|
import { X, Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
interface LoginModalProps {
|
interface LoginModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSwitchToSignup?: () => void;
|
onSwitchToSignup?: () => void;
|
||||||
|
onLoginSuccess?: (user: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitchToSignup }) => {
|
export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitchToSignup, onLoginSuccess }) => {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || 'Đăng nhập thất bại');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lưu phiên đăng nhập
|
||||||
|
localStorage.setItem('token', data.access_token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
|
||||||
|
if (onLoginSuccess) {
|
||||||
|
onLoginSuccess(data.user);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
@@ -34,7 +73,13 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="space-y-6" onSubmit={(e) => e.preventDefault()}>
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-50 text-red-600 rounded-2xl text-sm font-bold animate-in fade-in slide-in-from-top-1">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-semibold text-gray-700 ml-1">Email</label>
|
<label className="text-sm font-semibold text-gray-700 ml-1">Email</label>
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
@@ -42,6 +87,9 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
|
|||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="name@example.com"
|
placeholder="name@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,6 +105,9 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
|
|||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,10 +115,11 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-4 rounded-2xl shadow-lg shadow-blue-200 transition-all active:scale-[0.98]"
|
disabled={isLoading}
|
||||||
|
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-4 rounded-2xl shadow-lg shadow-blue-200 transition-all active:scale-[0.98] disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Đăng nhập
|
{isLoading ? 'Đang xử lý...' : 'Đăng nhập'}
|
||||||
<ArrowRight className="w-5 h-5" />
|
{isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : <ArrowRight className="w-5 h-5" />}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
+39
-5
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { User, Mail, Lock, ArrowRight, ChevronLeft, Compass } from 'lucide-react';
|
import { User, Mail, Lock, ArrowRight, ChevronLeft, Compass, Phone, MapPin } from 'lucide-react';
|
||||||
|
|
||||||
interface SignupPageProps {
|
interface SignupPageProps {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
@@ -11,7 +11,9 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
|
|||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
confirmPassword: ''
|
confirmPassword: '',
|
||||||
|
phone: '',
|
||||||
|
address: ''
|
||||||
});
|
});
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -27,13 +29,17 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://localhost:3001/api/v1/auth/signup', {
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/auth/signup`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
name: formData.name
|
name: formData.name,
|
||||||
|
phone: formData.phone || undefined,
|
||||||
|
address: formData.address || undefined
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,7 +49,6 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
|
|||||||
throw new Error(data.message || 'Đăng ký thất bại');
|
throw new Error(data.message || 'Đăng ký thất bại');
|
||||||
}
|
}
|
||||||
|
|
||||||
alert(data.isAdmin ? 'Đăng ký thành công! Bạn là Quản trị viên hệ thống.' : 'Đăng ký tài khoản thành công!');
|
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -121,6 +126,35 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-bold text-gray-700 ml-1">Số điện thoại</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
placeholder="0912 345 678"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||||
|
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-bold text-gray-700 ml-1">Địa chỉ</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Quận 1, TP.HCM"
|
||||||
|
value={formData.address}
|
||||||
|
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
||||||
|
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-bold text-gray-700 ml-1">Mật khẩu</label>
|
<label className="text-sm font-bold text-gray-700 ml-1">Mật khẩu</label>
|
||||||
|
|||||||
+802
-45
@@ -1,52 +1,370 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { ItineraryTimeline } from './ItineraryTimeline';
|
import { ItineraryTimeline } from './ItineraryTimeline.js';
|
||||||
import { ExpenseManager } from './ExpenseManager';
|
import { ExpenseManager } from './ExpenseManager.js';
|
||||||
import { useTourStore } from './useTourStore';
|
import { useTourStore } from './useTourStore.js';
|
||||||
|
import { AddLocationModal } from './AddLocationModal.js';
|
||||||
|
import { AddMemberModal } from './AddMemberModal.js';
|
||||||
|
import { ConfirmModal } from './ConfirmModal.js';
|
||||||
|
import { NotificationModal, useNotificationModal } from './components/NotificationModal.js';
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline } from 'react-leaflet';
|
||||||
|
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||||
|
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
||||||
|
import { useMap } from 'react-leaflet';
|
||||||
import {
|
import {
|
||||||
Map as MapIcon,
|
Map as MapIcon,
|
||||||
Wallet,
|
Wallet,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Calendar,
|
Calendar,
|
||||||
Users,
|
Users,
|
||||||
ChevronLeft
|
ChevronLeft,
|
||||||
|
Settings,
|
||||||
|
Quote,
|
||||||
|
Plus,
|
||||||
|
List,
|
||||||
|
Map as MapIconLucide,
|
||||||
|
MapPin,
|
||||||
|
Flag,
|
||||||
|
Clock,
|
||||||
|
Check,
|
||||||
|
X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
|
||||||
export const TourDetailPage = () => {
|
// Định nghĩa kiểu dữ liệu cho Địa điểm để khớp với Schema Prisma
|
||||||
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo'>('plan');
|
type LocationType = 'MOVE' | 'VISIT' | 'REST' | 'EAT';
|
||||||
const { currentTour, fetchTour, userRole } = useTourStore();
|
|
||||||
|
// Fix lỗi icon mặc định của Leaflet cho môi trường Vite
|
||||||
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||||
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||||
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Định nghĩa các static icons để ngăn chặn việc khởi tạo lại liên tục gây crash khi unmount
|
||||||
|
const START_ICON = L.divIcon({
|
||||||
|
className: 'custom-marker-s',
|
||||||
|
html: `<div class="w-6 h-6 bg-blue-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-[10px] font-black text-white">S</div>`,
|
||||||
|
iconSize: [24, 24],
|
||||||
|
iconAnchor: [12, 12]
|
||||||
|
});
|
||||||
|
|
||||||
|
const END_ICON = L.divIcon({
|
||||||
|
className: 'custom-marker-e',
|
||||||
|
html: `<div class="w-6 h-6 bg-green-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-[10px] font-black text-white">E</div>`,
|
||||||
|
iconSize: [24, 24],
|
||||||
|
iconAnchor: [12, 12]
|
||||||
|
});
|
||||||
|
|
||||||
|
const VISIT_ICON = L.divIcon({
|
||||||
|
className: 'custom-marker-v',
|
||||||
|
html: `<div class="w-4 h-4 bg-indigo-500 rounded-full border-2 border-white shadow-md"></div>`,
|
||||||
|
iconSize: [16, 16],
|
||||||
|
iconAnchor: [8, 8]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Component Helper để tự động điều chỉnh khung nhìn bản đồ bao phủ toàn bộ lộ trình
|
||||||
|
const MapTourBounds = ({ locations }: { locations: any[] }) => {
|
||||||
|
const map = useMap();
|
||||||
|
// Tạo một key dựa trên giá trị tọa độ để tránh chạy lại khi chỉ thay đổi tham chiếu mảng
|
||||||
|
const locKey = useMemo(() => JSON.stringify(locations.map(l => [l.latitude, l.longitude])), [locations]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Gọi ID 1 (Dữ liệu từ file seed) để kiểm tra
|
if (locations.length > 0) {
|
||||||
fetchTour(1);
|
const bounds = L.latLngBounds(locations.map(l => [l.latitude, l.longitude]));
|
||||||
}, [fetchTour]);
|
if (locations.length === 1) {
|
||||||
|
// Chỉ thực hiện nếu bản đồ chưa ở đúng vị trí (tránh trigger moveend liên tục)
|
||||||
|
map.setView([locations[0].latitude, locations[0].longitude], 15, { animate: true });
|
||||||
|
} else {
|
||||||
|
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [locKey, map]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
// Menu ngữ cảnh cho bản đồ
|
||||||
|
const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.LatLng) => void }) => {
|
||||||
|
const [menuPos, setMenuPos] = useState<{ x: number, y: number, latlng: L.LatLng } | null>(null);
|
||||||
|
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Sử dụng selector để tránh re-render khi mapCenter thay đổi
|
||||||
|
const legs = useTourStore(state => state.legs);
|
||||||
|
const setMapCenter = useTourStore(state => state.setMapCenter);
|
||||||
|
|
||||||
|
useMapEvents({
|
||||||
|
contextmenu: (e) => {
|
||||||
|
// Ngăn menu mặc định của trình duyệt hiện lên.
|
||||||
|
if (e.originalEvent) {
|
||||||
|
L.DomEvent.preventDefault(e.originalEvent);
|
||||||
|
L.DomEvent.stopPropagation(e.originalEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[MAP] Context menu triggered at: ${e.latlng.lat}, ${e.latlng.lng}`);
|
||||||
|
setMenuPos({ x: e.containerPoint.x, y: e.containerPoint.y, latlng: e.latlng });
|
||||||
|
},
|
||||||
|
|
||||||
|
moveend: (e) => {
|
||||||
|
const map = e.target;
|
||||||
|
const center = map.getCenter();
|
||||||
|
const zoom = map.getZoom();
|
||||||
|
const coords: [number, number] = [center.lat, center.lng];
|
||||||
|
|
||||||
|
// Chỉ cập nhật store nếu tọa độ thay đổi đáng kể (> 0.0001) để tránh loop
|
||||||
|
const currentStored = useTourStore.getState().mapCenter;
|
||||||
|
const diff = Math.abs(currentStored[0] - coords[0]) + Math.abs(currentStored[1] - coords[1]);
|
||||||
|
|
||||||
|
if (diff > 0.0001) {
|
||||||
|
setMapCenter(coords);
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('map_view_state', JSON.stringify({ center: coords, zoom }));
|
||||||
|
},
|
||||||
|
click: () => setMenuPos(null),
|
||||||
|
dragstart: () => setMenuPos(null),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ngăn chặn các sự kiện của bản đồ khi tương tác với menu
|
||||||
|
useEffect(() => {
|
||||||
|
if (menuPos && menuRef.current) {
|
||||||
|
L.DomEvent.disableClickPropagation(menuRef.current);
|
||||||
|
L.DomEvent.disableScrollPropagation(menuRef.current);
|
||||||
|
}
|
||||||
|
}, [menuPos]);
|
||||||
|
|
||||||
|
if (!menuPos) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="absolute z-[2000] bg-white rounded-2xl shadow-2xl border border-gray-100 py-2 w-48 animate-in zoom-in-95 duration-200"
|
||||||
|
style={{ top: menuPos.y, left: menuPos.x }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<button onClick={() => { onAction('START', menuPos.latlng); setMenuPos(null); }} className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-gray-700 flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-blue-600" /> Bắt đầu từ đây
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { onAction('END', menuPos.latlng); setMenuPos(null); }} className="w-full text-left px-4 py-2 hover:bg-green-50 text-sm font-bold text-gray-700 flex items-center gap-2 border-b border-gray-50">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-green-600" /> Kết thúc ở đây
|
||||||
|
</button>
|
||||||
|
<div className="px-4 py-2 text-[10px] font-black text-gray-400 uppercase tracking-widest">Thêm vào chặng</div>
|
||||||
|
{legs.map(leg => (
|
||||||
|
<button
|
||||||
|
key={leg.id}
|
||||||
|
onClick={() => { onAction(`ADD_TO_LEG_${leg.id}`, menuPos.latlng); setMenuPos(null); }}
|
||||||
|
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-medium text-gray-600 truncate"
|
||||||
|
>
|
||||||
|
Chặng {leg.sequence}: {leg.note || 'Không có ghi chú'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings'>('plan');
|
||||||
|
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
|
||||||
|
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
|
||||||
|
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
|
||||||
|
const [targetLegId, setTargetLegId] = useState<string | null>(null);
|
||||||
|
const [editingLocation, setEditingLocation] = useState<any>(null);
|
||||||
|
const [selectedMember, setSelectedMember] = useState<any>(null);
|
||||||
|
const [isMemberDetailOpen, setIsMemberDetailOpen] = useState(false);
|
||||||
|
const [joinRequests, setJoinRequests] = useState<any[]>([]);
|
||||||
|
const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(null);
|
||||||
|
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentTour, legs, fetchTour, fetchPublicTours, publicTours,
|
||||||
|
userRole, mapCenter, setMapCenter, updateTourStartPoint,
|
||||||
|
updateTourEndPoint, initializeLegs, addLocation, addMember, removeMember,
|
||||||
|
fetchJoinRequests, acceptJoinRequest, rejectJoinRequest
|
||||||
|
} = useTourStore();
|
||||||
|
|
||||||
|
const notificationModal = useNotificationModal();
|
||||||
|
|
||||||
|
// Khôi phục vị trí và mức zoom từ localStorage
|
||||||
|
const [initialViewState] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('map_view_state');
|
||||||
|
if (saved) {
|
||||||
|
try { return JSON.parse(saved); } catch (e) { return null; }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// SỬA LỖI: Sử dụng các selectors riêng lẻ để tránh re-render trang khi mapCenter trong store thay đổi
|
||||||
|
|
||||||
|
const canEdit = ['OWNER', 'MANAGER'].includes(userRole || '');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentTour && ['OWNER', 'MANAGER'].includes(userRole || '')) {
|
||||||
|
fetchJoinRequests(currentTour.id).then(setJoinRequests).catch(() => setJoinRequests([]));
|
||||||
|
}
|
||||||
|
}, [currentTour, userRole]);
|
||||||
|
|
||||||
|
const [mapZoom] = useState(initialViewState?.zoom || 13);
|
||||||
|
|
||||||
|
// Memoize danh sách tọa độ để MapTourBounds không bị trigger thừa
|
||||||
|
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialViewState) {
|
||||||
|
setMapCenter(initialViewState.center);
|
||||||
|
}
|
||||||
|
const loadData = async () => {
|
||||||
|
// Nếu chưa có tour nào trong store, thử tải danh sách public trước
|
||||||
|
if (publicTours.length === 0) {
|
||||||
|
await fetchPublicTours();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Lấy tour đầu tiên từ danh sách khám phá để hiển thị demo
|
||||||
|
if (publicTours.length > 0 && !currentTour) {
|
||||||
|
fetchTour(publicTours[0].id);
|
||||||
|
}
|
||||||
|
}, [publicTours, currentTour, fetchTour]);
|
||||||
|
|
||||||
|
// Hàm xử lý khai báo số chặng
|
||||||
|
const handleDeclareLegs = async () => {
|
||||||
|
const countStr = window.prompt("Chuyến đi này bạn muốn chia làm bao nhiêu chặng?", "3");
|
||||||
|
const count = parseInt(countStr || "0");
|
||||||
|
if (count > 0 && currentTour) {
|
||||||
|
await initializeLegs(currentTour.id, count);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hàm xử lý các hành động từ Context Menu của bản đồ
|
||||||
|
const handleMapAction = async (action: string, latlng: L.LatLng) => {
|
||||||
|
// Bước 1: Lấy tọa độ (lat, lng) tại vị trí click (đã nhận qua tham số latlng)
|
||||||
|
console.log(`[FRONTEND] Triggered ${action} at:`, { lat: latlng.lat, lng: latlng.lng });
|
||||||
|
|
||||||
|
// Đảm bảo có tour và ít nhất một chặng để ghim
|
||||||
|
const currentLegs = useTourStore.getState().legs;
|
||||||
|
if (!currentTour || currentLegs.length === 0) {
|
||||||
|
alert("Tour chưa có chặng nào. Vui lòng tạo chặng (Leg) trước khi thực hiện.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetLegId = currentLegs[0].id; // Mặc định là chặng đầu
|
||||||
|
let defaultName = "Địa điểm mới";
|
||||||
|
let locationType: LocationType = 'VISIT'; // Mặc định là tham quan
|
||||||
|
|
||||||
|
if (action === 'START') {
|
||||||
|
defaultName = "Điểm bắt đầu";
|
||||||
|
locationType = 'MOVE'; // Điểm bắt đầu thường liên quan đến di chuyển
|
||||||
|
}
|
||||||
|
if (action === 'END') {
|
||||||
|
targetLegId = currentLegs[currentLegs.length - 1].id;
|
||||||
|
defaultName = "Điểm kết thúc";
|
||||||
|
locationType = 'MOVE'; // Điểm kết thúc cũng liên quan đến di chuyển
|
||||||
|
}
|
||||||
|
if (action.startsWith('ADD_TO_LEG_')) {
|
||||||
|
targetLegId = action.replace('ADD_TO_LEG_', '');
|
||||||
|
// Loại địa điểm mặc định vẫn là VISIT nếu thêm vào chặng cụ thể
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bước 2: Gửi request đến dịch vụ bản đồ để phân tích tọa độ thành tên địa điểm cụ thể
|
||||||
|
let detectedName = "";
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latlng.lat}&lon=${latlng.lng}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const addr = data.address;
|
||||||
|
// Ưu tiên lấy tên Location/Tòa nhà/Tên đường, bỏ qua Tỉnh/Thành phố nếu có thông tin chi tiết hơn
|
||||||
|
detectedName = addr.amenity || addr.building || addr.historic || addr.tourist ||
|
||||||
|
addr.shop || addr.office || addr.leisure || addr.attraction ||
|
||||||
|
addr.road || addr.neighbourhood || addr.suburb ||
|
||||||
|
data.display_name?.split(',')[0] || "";
|
||||||
|
console.log(`[FRONTEND] Geocoding Result: "${detectedName}"`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[FRONTEND] Reverse Geocoding failed:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === 'START') {
|
||||||
|
// Bước 3: Mutate State & UI - Đặt startLocationName = resolvedPlaceName
|
||||||
|
const resolvedPlaceName = detectedName || "Điểm xuất phát";
|
||||||
|
console.log(`[FRONTEND] Updating START point to: ${resolvedPlaceName} at`, latlng);
|
||||||
|
await updateTourStartPoint(currentTour.id, {
|
||||||
|
name: resolvedPlaceName,
|
||||||
|
latitude: latlng.lat,
|
||||||
|
longitude: latlng.lng,
|
||||||
|
});
|
||||||
|
console.log("[FRONTEND] START point updated successfully.");
|
||||||
|
// Giao diện Top Banner và Ghim màu xanh sẽ tự động cập nhật
|
||||||
|
// khi store fetch lại dữ liệu tour và re-render.
|
||||||
|
} else if (action === 'END') {
|
||||||
|
// Bước 2: Thiết lập Điểm kết thúc
|
||||||
|
const finalName = detectedName || "Điểm kết thúc";
|
||||||
|
await updateTourEndPoint(currentTour.id, {
|
||||||
|
name: finalName,
|
||||||
|
latitude: latlng.lat,
|
||||||
|
longitude: latlng.lng,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Đối với việc thêm địa điểm vào chặng, vẫn sử dụng Prompt để người dùng đặt tên theo ý muốn
|
||||||
|
const name = window.prompt("Xác nhận tên địa điểm tham quan:", detectedName || defaultName);
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
await addLocation(currentTour.id, {
|
||||||
|
name,
|
||||||
|
address: '',
|
||||||
|
latitude: latlng.lat,
|
||||||
|
longitude: latlng.lng,
|
||||||
|
legId: targetLegId,
|
||||||
|
type: locationType as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// Xử lý lỗi từ API (Ví dụ: Tour chưa có chặng nào)
|
||||||
|
if (error.message.includes('Không tìm thấy chặng')) {
|
||||||
|
alert("Lỗi: Bạn cần tạo ít nhất một Chặng (Leg) trước khi xác định điểm Bắt đầu/Kết thúc.");
|
||||||
|
} else {
|
||||||
|
alert("Đã xảy ra lỗi: " + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Xác định Điểm xuất phát và Điểm kết thúc hiển thị dưới widget tài chính
|
||||||
|
const startPoint = legs[0]?.locations[0];
|
||||||
|
const lastLeg = legs[legs.length - 1];
|
||||||
|
const endPoint = lastLeg?.locations[lastLeg.locations.length - 1];
|
||||||
|
|
||||||
// Định nghĩa các tab dựa trên quyền hạn (RBAC) từ store
|
// Định nghĩa các tab dựa trên quyền hạn (RBAC) từ store
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: ['OWNER', 'EDITOR', 'MEMBER_PLAN_ONLY'].includes(userRole || '') },
|
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: true },
|
||||||
{ id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'EDITOR'].includes(userRole || '') },
|
{ id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '') },
|
||||||
{ id: 'photo', label: 'Ảnh', icon: ImageIcon, visible: true },
|
{ id: 'photo', label: 'Ảnh', icon: ImageIcon, visible: true },
|
||||||
|
{ id: 'settings', label: 'Cài đặt', icon: Settings, visible: userRole === 'OWNER' },
|
||||||
].filter(t => t.visible);
|
].filter(t => t.visible);
|
||||||
|
|
||||||
// Tự động chuyển tab nếu người dùng không có quyền xem 'plan'
|
const hasFinanceAccess = ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
||||||
useEffect(() => {
|
|
||||||
if (userRole === 'MEMBER_PHOTO_ONLY') {
|
const travelQuotes = [
|
||||||
setActiveTab('photo');
|
"Đừng nghe họ nói, hãy tự mình đi xem.",
|
||||||
}
|
"Thế giới là một cuốn sách, và ai không đi du lịch thì chỉ mới đọc được một trang.",
|
||||||
}, [userRole]);
|
"Hành trình ngàn dặm bắt đầu từ một bước chân.",
|
||||||
|
"Đi là để trở về, nhưng với một tâm hồn mới."
|
||||||
|
];
|
||||||
|
|
||||||
|
const randomQuote = useMemo(() => travelQuotes[Math.floor(Math.random() * travelQuotes.length)], []);
|
||||||
|
|
||||||
// Mock dữ liệu header (Trong thực tế sẽ lấy từ currentTour)
|
|
||||||
const tourInfo = {
|
const tourInfo = {
|
||||||
title: currentTour?.title || "Hành trình khám phá TP.HCM",
|
title: currentTour?.title || "Hành trình khám phá TP.HCM",
|
||||||
date: "20 - 21 Tháng 11, 2023",
|
date: currentTour?.startDate ? `${new Date(currentTour.startDate).toLocaleDateString('vi-VN')} - ${new Date(currentTour.endDate).toLocaleDateString('vi-VN')}` : "Chưa xác định ngày",
|
||||||
members: 5,
|
membersCount: currentTour?.participants?.length || 0,
|
||||||
budget: "2.500.000 VND"
|
budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND",
|
||||||
|
coverImage: currentTour?.photos?.[0]?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-gray-50 pb-20">
|
||||||
{/* Top Navigation Bar */}
|
{/* Top Navigation Bar */}
|
||||||
<div className="sticky top-0 z-30 bg-white border-b border-gray-100 px-4 py-3 flex items-center justify-between">
|
<div className="sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-gray-100 px-4 py-3 flex items-center justify-between">
|
||||||
<button className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||||
<ChevronLeft className="w-6 h-6 text-gray-600" />
|
<ChevronLeft className="w-6 h-6 text-gray-600" />
|
||||||
</button>
|
</button>
|
||||||
<h1 className="text-lg font-bold text-gray-800 truncate px-4">
|
<h1 className="text-lg font-bold text-gray-800 truncate px-4">
|
||||||
@@ -56,29 +374,198 @@ export const TourDetailPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tour Header Info */}
|
{/* Tour Header Info */}
|
||||||
<div className="bg-blue-600 text-white p-6 pb-20">
|
<div className="relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end">
|
||||||
|
<img
|
||||||
|
src={tourInfo.coverImage}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover opacity-60"
|
||||||
|
alt="Tour Cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
|
||||||
|
|
||||||
|
<div className="relative z-10 p-6 text-white pt-28 pb-20">
|
||||||
<div className="max-w-2xl mx-auto space-y-4">
|
<div className="max-w-2xl mx-auto space-y-4">
|
||||||
<div className="flex flex-wrap gap-4 text-sm opacity-90">
|
<h2 className="text-3xl font-black tracking-tight drop-shadow-md">{tourInfo.title}</h2>
|
||||||
<div className="flex items-center">
|
{/* Dòng tóm tắt Lộ trình */}
|
||||||
|
<div className="mt-3 text-[11px] sm:text-sm font-bold text-white bg-black/40 backdrop-blur-md px-4 py-2 rounded-xl border border-white/10 inline-flex items-center max-w-full">
|
||||||
|
<span className="text-white/60 mr-1">Lộ trình:</span>
|
||||||
|
<span className="text-blue-300">Điểm xuất phát:</span>
|
||||||
|
<span className="ml-1 text-white banner-location-text" title={startPoint?.name}>{startPoint?.name || '...'}</span>
|
||||||
|
<span className="mx-2 text-white/30">-</span>
|
||||||
|
<span className="text-green-300">Điểm kết thúc:</span>
|
||||||
|
<span className="ml-1 text-white banner-location-text" title={endPoint?.name}>{endPoint?.name || '...'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4 text-sm font-medium opacity-90">
|
||||||
|
<div className="flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10">
|
||||||
<Calendar className="w-4 h-4 mr-1.5" />
|
<Calendar className="w-4 h-4 mr-1.5" />
|
||||||
{tourInfo.date}
|
{tourInfo.date}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10">
|
||||||
<Users className="w-4 h-4 mr-1.5" />
|
<Users className="w-4 h-4 mr-1.5" />
|
||||||
{tourInfo.members} thành viên
|
{tourInfo.membersCount} thành viên
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<span className="text-3xl font-bold">{tourInfo.budget}</span>
|
|
||||||
<span className="text-blue-100 text-sm">dự kiến</span>
|
|
||||||
|
{/* Member Avatars Stack */}
|
||||||
|
<div className="flex items-center gap-2 mt-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{currentTour?.participants?.slice(0, 5).map((p: any, i: number) => (
|
||||||
|
<button
|
||||||
|
key={p.userId || i}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedMember(p);
|
||||||
|
setIsMemberDetailOpen(true);
|
||||||
|
}}
|
||||||
|
className="w-10 h-10 rounded-full border-2 border-white bg-blue-100 flex items-center justify-center text-blue-600 font-bold overflow-hidden shadow-lg hover:scale-110 transition-transform"
|
||||||
|
title={p.user?.name || p.userId}
|
||||||
|
>
|
||||||
|
<img src={`https://i.pravatar.cc/100?u=${p.userId}`} alt="Avatar" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{joinRequests.slice(0, 3).map((req: any) => (
|
||||||
|
<div key={req.id} className="relative group">
|
||||||
|
<div className="w-10 h-10 rounded-full border-2 border-amber-400 bg-amber-50 flex items-center justify-center text-amber-700 font-bold overflow-hidden shadow-lg">
|
||||||
|
{req.user?.name?.charAt(0) || '?'}
|
||||||
|
</div>
|
||||||
|
<div className="absolute -top-1 -right-1 flex">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={joinRequestActionId === req.id}
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!currentTour) return;
|
||||||
|
setConfirmState({
|
||||||
|
open: true,
|
||||||
|
title: 'Chấp nhận yêu cầu',
|
||||||
|
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`,
|
||||||
|
onConfirm: async () => {
|
||||||
|
setJoinRequestActionId(req.id);
|
||||||
|
try {
|
||||||
|
await acceptJoinRequest(currentTour.id, req.id);
|
||||||
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||||
|
} catch (e: any) {
|
||||||
|
notificationModal.openModal('Thông báo', e.message || 'Không thể chấp nhận yêu cầu', 'error');
|
||||||
|
} finally {
|
||||||
|
setJoinRequestActionId(null);
|
||||||
|
setConfirmState({ open: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50"
|
||||||
|
aria-label="Accept"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={joinRequestActionId === req.id}
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!currentTour) return;
|
||||||
|
setConfirmState({
|
||||||
|
open: true,
|
||||||
|
title: 'Từ chối yêu cầu',
|
||||||
|
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`,
|
||||||
|
onConfirm: async () => {
|
||||||
|
setJoinRequestActionId(req.id);
|
||||||
|
try {
|
||||||
|
await rejectJoinRequest(currentTour.id, req.id);
|
||||||
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||||
|
} catch (e: any) {
|
||||||
|
notificationModal.openModal('Thông báo', e.message || 'Không thể từ chối yêu cầu', 'error');
|
||||||
|
} finally {
|
||||||
|
setJoinRequestActionId(null);
|
||||||
|
setConfirmState({ open: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1"
|
||||||
|
aria-label="Reject"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{tourInfo.membersCount > 5 && (
|
||||||
|
<div className="w-10 h-10 rounded-full border-2 border-white bg-gray-800 flex items-center justify-center text-[10px] font-bold text-white shadow-lg">
|
||||||
|
+{tourInfo.membersCount - 5}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!currentTour) return;
|
||||||
|
if (canEdit) setIsAddMemberOpen(true);
|
||||||
|
}}
|
||||||
|
disabled={!canEdit}
|
||||||
|
className={`p-2 rounded-full border backdrop-blur-sm transition-all ml-2 ${
|
||||||
|
canEdit ? 'bg-white/10 hover:bg-white/20 border-white/20' : 'bg-white/5 border-white/10 opacity-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Financial Quick-View Widget or Quote */}
|
||||||
<div className="max-w-2xl mx-auto -mt-12 px-4 pb-24">
|
<div className="max-w-2xl mx-auto -mt-10 px-4 relative z-10">
|
||||||
|
<div className={`rounded-3xl p-6 shadow-2xl transition-all duration-500 ${hasFinanceAccess ? 'bg-indigo-600 text-white shadow-indigo-200' : 'bg-white text-gray-600 border border-gray-100'}`}>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
{hasFinanceAccess ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<p className="text-indigo-100 text-xs font-black uppercase tracking-widest mb-1">Tổng chi tiêu hiện tại</p>
|
||||||
|
<h3 className="text-3xl font-black">{tourInfo.budget}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-white/10 rounded-2xl backdrop-blur-md"><Wallet className="w-8 h-8" /></div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start gap-4 py-2">
|
||||||
|
<Quote className="w-8 h-8 text-blue-500 opacity-30 flex-shrink-0" />
|
||||||
|
<p className="italic text-lg font-medium leading-relaxed">"{randomQuote}"</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Khối hiển thị Điểm đầu & Điểm cuối (Dưới Financial Quick-View) */}
|
||||||
|
<div className="max-w-2xl mx-auto mt-4 px-4 grid grid-cols-1 sm:grid-cols-2 gap-3 animate-in fade-in slide-in-from-top-2 duration-500">
|
||||||
|
{startPoint && (
|
||||||
|
<div className="bg-white p-4 rounded-2xl border border-blue-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center text-blue-600 shadow-inner">
|
||||||
|
<MapPin className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<p className="text-[10px] font-black text-blue-400 uppercase tracking-widest mb-0.5">Điểm xuất phát</p>
|
||||||
|
<p className="text-sm font-bold text-gray-800 truncate">{startPoint.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{endPoint && (
|
||||||
|
<div className="bg-white p-4 rounded-2xl border border-green-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center text-green-600 shadow-inner">
|
||||||
|
<Flag className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<p className="text-[10px] font-black text-green-400 uppercase tracking-widest mb-0.5">Điểm kết thúc</p>
|
||||||
|
<p className="text-sm font-bold text-gray-800 truncate">{endPoint.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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`}>
|
||||||
{/* Tab Switcher */}
|
{/* Tab Switcher */}
|
||||||
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-1 flex mb-6">
|
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-1 flex mb-6 sticky top-20 z-20">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
@@ -86,10 +573,10 @@ export const TourDetailPage = () => {
|
|||||||
className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${
|
className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'bg-blue-50 text-blue-600 shadow-sm'
|
? 'bg-blue-50 text-blue-600 shadow-sm'
|
||||||
: 'text-gray-400 hover:text-gray-600'
|
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<tab.icon className={`w-4 h-4 mr-2 ${activeTab === tab.id ? 'animate-pulse' : ''}`} />
|
<tab.icon className={`w-4 h-4 mr-2 ${activeTab === tab.id ? 'scale-110' : ''}`} />
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -98,8 +585,84 @@ export const TourDetailPage = () => {
|
|||||||
{/* Tab Panels */}
|
{/* Tab Panels */}
|
||||||
<div className="transition-opacity duration-300">
|
<div className="transition-opacity duration-300">
|
||||||
{activeTab === 'plan' && (
|
{activeTab === 'plan' && (
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-4">
|
<div className="animate-in fade-in slide-in-from-bottom-2">
|
||||||
<ItineraryTimeline />
|
{/* View Mode Toggle */}
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<div className="bg-gray-100 p-1 rounded-2xl flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('timeline')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'timeline' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
|
<List className="w-3.5 h-3.5" /> Danh sách
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('map')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'map' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
|
<MapIconLucide className="w-3.5 h-3.5" /> Bản đồ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewMode === 'timeline' ? (
|
||||||
|
<ItineraryTimeline onAddLocation={(legId) => {
|
||||||
|
setTargetLegId(legId);
|
||||||
|
setEditingLocation(null);
|
||||||
|
setIsAddLocationOpen(true);
|
||||||
|
}} onEditLocation={(loc) => {
|
||||||
|
setEditingLocation(loc);
|
||||||
|
setTargetLegId(loc.legId);
|
||||||
|
setMapCenter([loc.latitude, loc.longitude]);
|
||||||
|
setIsAddLocationOpen(true);
|
||||||
|
}} />
|
||||||
|
) : (
|
||||||
|
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative">
|
||||||
|
<MapContainer
|
||||||
|
center={initialViewState?.center || mapCenter}
|
||||||
|
zoom={mapZoom}
|
||||||
|
className="h-full w-full"
|
||||||
|
preferCanvas={true}
|
||||||
|
>
|
||||||
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||||
|
{canEdit && <MapContextMenu onAction={handleMapAction} />}
|
||||||
|
|
||||||
|
{/* Tự động đóng khung Start và End khi dữ liệu thay đổi */}
|
||||||
|
<MapTourBounds locations={allLocations} />
|
||||||
|
|
||||||
|
{/* Vẽ đường Polyline nối các điểm - Liên tục toàn bộ lộ trình xuyên suốt các chặng */}
|
||||||
|
{allLocations.length > 1 && (
|
||||||
|
<Polyline
|
||||||
|
positions={allLocations.map(l => [l.latitude, l.longitude]) as any}
|
||||||
|
color="#3b82f6"
|
||||||
|
weight={3}
|
||||||
|
dashArray="5, 10"
|
||||||
|
smoothFactor={1.5}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MarkerClusterGroup chunkedLoading>
|
||||||
|
{legs.flatMap(l => l.locations).map((loc: any) => {
|
||||||
|
const isStart = startPoint?.id === loc.id;
|
||||||
|
const isEnd = endPoint?.id === loc.id;
|
||||||
|
// Sử dụng các icon tĩnh đã định nghĩa ở trên
|
||||||
|
const icon = isStart ? START_ICON : isEnd ? END_ICON : VISIT_ICON;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Marker key={loc.id} position={[loc.latitude, loc.longitude]} icon={icon}>
|
||||||
|
<Popup>
|
||||||
|
<div className="font-bold">{loc.name}</div>
|
||||||
|
<div className="text-xs text-gray-500">{loc.type}</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</MarkerClusterGroup>
|
||||||
|
</MapContainer>
|
||||||
|
<div className="absolute top-4 left-4 z-[1000] bg-white/90 backdrop-blur-md p-3 rounded-2xl text-[10px] font-bold text-gray-500 shadow-lg border border-white">
|
||||||
|
Mẹo: Nhấn giữ (Mobile) hoặc Chuột phải để ghim địa điểm
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -110,28 +673,222 @@ export const TourDetailPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'photo' && (
|
{activeTab === 'photo' && (
|
||||||
<div className="grid grid-cols-3 gap-2 animate-in fade-in">
|
<div className="grid grid-cols-3 gap-1.5 animate-in fade-in">
|
||||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
<div key={i} className="aspect-square bg-gray-100 rounded-lg overflow-hidden relative group">
|
<div key={i} className="aspect-square bg-gray-200 rounded-xl overflow-hidden relative group border border-white">
|
||||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
||||||
<img
|
<img
|
||||||
src={`https://picsum.photos/seed/${i + 10}/400/400`}
|
src={`https://picsum.photos/seed/${i + 10}/400/400`}
|
||||||
alt="Tour photo"
|
alt="Tour photo"
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'settings' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-6 bg-white rounded-3xl border border-dashed border-gray-200 animate-in zoom-in-95">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<Clock className="w-6 h-6 text-blue-500" />
|
||||||
|
<h3 className="text-lg font-bold text-gray-900">Yêu cầu tham gia</h3>
|
||||||
|
<span className="text-xs font-bold text-gray-500 bg-gray-100 px-2 py-1 rounded-full">{joinRequests.length} đang chờ</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{joinRequests.map((req: any) => (
|
||||||
|
<div key={req.id} className="flex items-center justify-between gap-3 p-3 rounded-2xl border border-gray-100 bg-white">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-700 font-bold text-sm">
|
||||||
|
{req.user?.name?.charAt(0) || '?'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-gray-800">{req.user?.name || req.userId}</div>
|
||||||
|
<div className="text-[11px] text-gray-500">
|
||||||
|
Được mời bởi {req.requestedBy?.name} • {new Date(req.createdAt).toLocaleString('vi-VN')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
disabled={joinRequestActionId === req.id}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!currentTour) return;
|
||||||
|
setConfirmState({
|
||||||
|
open: true,
|
||||||
|
title: 'Chấp nhận yêu cầu',
|
||||||
|
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`,
|
||||||
|
onConfirm: async () => {
|
||||||
|
setJoinRequestActionId(req.id);
|
||||||
|
try {
|
||||||
|
await acceptJoinRequest(currentTour.id, req.id);
|
||||||
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||||
|
} catch (e: any) {
|
||||||
|
notificationModal.openModal('Thông báo', e.message || 'Không thể chấp nhận yêu cầu', 'error');
|
||||||
|
} finally {
|
||||||
|
setJoinRequestActionId(null);
|
||||||
|
setConfirmState({ open: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="p-2 rounded-xl bg-green-50 text-green-700 hover:bg-green-100 disabled:opacity-50"
|
||||||
|
aria-label="Accept"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={joinRequestActionId === req.id}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!currentTour) return;
|
||||||
|
setConfirmState({
|
||||||
|
open: true,
|
||||||
|
title: 'Từ chối yêu cầu',
|
||||||
|
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`,
|
||||||
|
onConfirm: async () => {
|
||||||
|
setJoinRequestActionId(req.id);
|
||||||
|
try {
|
||||||
|
await rejectJoinRequest(currentTour.id, req.id);
|
||||||
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||||
|
} catch (e: any) {
|
||||||
|
notificationModal.openModal('Thông báo', e.message || 'Không thể từ chối yêu cầu', 'error');
|
||||||
|
} finally {
|
||||||
|
setJoinRequestActionId(null);
|
||||||
|
setConfirmState({ open: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="p-2 rounded-xl bg-red-50 text-red-700 hover:bg-red-100 disabled:opacity-50"
|
||||||
|
aria-label="Reject"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{joinRequests.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-sm text-gray-500">Không có yêu cầu tham gia nào đang chờ phê duyệt.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-8 text-center bg-gray-50 rounded-3xl border border-dashed border-gray-200">
|
||||||
|
<Settings className="w-10 h-10 text-gray-300 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-500 font-medium">Tính năng cài đặt khác đang được cập nhật...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating Action Button (Mobile) */}
|
{/* Floating Action Button (Mobile) */}
|
||||||
|
{canEdit && (
|
||||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
|
||||||
<button className="bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold">
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTargetLegId(null); // Reset khi nhấn nút floating tổng quát
|
||||||
|
setEditingLocation(null);
|
||||||
|
if (activeTab === 'plan') setIsAddLocationOpen(true);
|
||||||
|
}}
|
||||||
|
className="bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold">
|
||||||
{activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh'}
|
{activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Member Modal */}
|
||||||
|
{currentTour && (
|
||||||
|
<AddMemberModal
|
||||||
|
isOpen={isAddMemberOpen}
|
||||||
|
onClose={() => setIsAddMemberOpen(false)}
|
||||||
|
tourId={currentTour.id}
|
||||||
|
participants={currentTour.participants || []}
|
||||||
|
joinRequests={joinRequests}
|
||||||
|
onRemoveMember={(userId) => removeMember(currentTour.id, userId)}
|
||||||
|
onMemberAdded={() => fetchTour(currentTour.id)}
|
||||||
|
userRole={userRole || undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Location Modal */}
|
||||||
|
{currentTour && (
|
||||||
|
<AddLocationModal
|
||||||
|
isOpen={isAddLocationOpen}
|
||||||
|
onClose={() => setIsAddLocationOpen(false)}
|
||||||
|
initialLegId={targetLegId || undefined}
|
||||||
|
editingLocation={editingLocation}
|
||||||
|
tourId={currentTour.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Member Detail Popover */}
|
||||||
|
{isMemberDetailOpen && selectedMember && (
|
||||||
|
<div className="fixed inset-0 z-[2100] flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={() => setIsMemberDetailOpen(false)} />
|
||||||
|
<div className="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold">
|
||||||
|
{selectedMember.user?.name?.charAt(0) || '?'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-bold text-gray-900">{selectedMember.user?.name || 'Chưa đặt tên'}</div>
|
||||||
|
<div className="text-xs text-gray-500">{selectedMember.user?.email}</div>
|
||||||
|
<div className="text-[10px] font-semibold text-gray-500">{selectedMember.role}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(selectedMember.user?.phone || selectedMember.user?.address) && (
|
||||||
|
<div className="mt-3 text-xs text-gray-600 space-y-1">
|
||||||
|
{selectedMember.user?.phone && <div>📞 {selectedMember.user.phone}</div>}
|
||||||
|
{selectedMember.user?.address && <div>📍 {selectedMember.user.address}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<button onClick={() => setIsMemberDetailOpen(false)} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100">Đóng</button>
|
||||||
|
{canEdit && selectedMember.role !== 'OWNER' && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!currentTour || !selectedMember) return;
|
||||||
|
try {
|
||||||
|
await removeMember(currentTour.id, selectedMember.userId);
|
||||||
|
setIsMemberDetailOpen(false);
|
||||||
|
} catch (e) {
|
||||||
|
notificationModal.openModal('Thông báo', 'Không thể xóa thành viên', 'error');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold"
|
||||||
|
>
|
||||||
|
Xóa
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canEdit && selectedMember.role === 'OWNER' && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsMemberDetailOpen(false);
|
||||||
|
setIsAddMemberOpen(true);
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl text-sm font-bold"
|
||||||
|
>
|
||||||
|
Mời thêm người
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={confirmState.open}
|
||||||
|
title={confirmState.title}
|
||||||
|
message={confirmState.message}
|
||||||
|
onConfirm={() => confirmState.onConfirm?.()}
|
||||||
|
onCancel={() => setConfirmState({ open: false })}
|
||||||
|
/>
|
||||||
|
<NotificationModal
|
||||||
|
isOpen={notificationModal.modalState?.isOpen ?? false}
|
||||||
|
title={notificationModal.modalState?.title}
|
||||||
|
message={notificationModal.modalState?.message}
|
||||||
|
type={notificationModal.modalState?.type}
|
||||||
|
onConfirm={() => notificationModal.closeModal()}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# KẾ HOẠCH PHÁT TRIỂN TÍNH NĂNG ĐỊNH VỊ LỘ TRÌNH (AGENT SPECIFICATION)
|
||||||
|
|
||||||
|
## 1. Phân Tích Trạng Thái Giao Diện Hiện Tại (Context Analysis)
|
||||||
|
* **Màn hình**: Tour Dashboard (Tên tour người dùng tự đặt khi tạo tour mới).
|
||||||
|
* **Tab Active**: Lộ trình (Mặc định).
|
||||||
|
* **Sub-view Active**: Bản đồ (Component bản đồ đang hiển thị ở nửa dưới màn hình).
|
||||||
|
* **Thành phần cần can thiệp**: Lớp tương tác (Interaction Layer) của thư viện Bản đồ (Mapbox / Google Maps API / Leaflet) đang render phía dưới.
|
||||||
|
│
|
||||||
|
|
||||||
|
## 2. Đặc Tả Kỹ Thuật Đóng Gói (Functional Requirements)
|
||||||
|
|
||||||
|
### 2.1. Quản lý Trạng thái Thực thể (Data State Management)
|
||||||
|
Agent cần ánh xạ sự kiện trên UI vào hai trường dữ liệu trong Database Schema:
|
||||||
|
* **Điểm xuất phát (StartLocation)**: Ánh xạ vào Location đầu tiên của Tour với sequence: 0 hoặc cấu hình tọa độ trực tiếp vào cấu trúc metadata của Tour.
|
||||||
|
* **Điểm kết thúc (EndLocation)**: Ánh xạ vào Location cuối cùng của Tour hoặc trường đích của Tour.
|
||||||
|
|
||||||
|
### 2.2. Chi Tiết Bản Ghép Logic Sự Kiện (Event Mapping)
|
||||||
|
|
||||||
|
| Thao tác Người dùng | Trình kích hoạt (Trigger) | Hành động Hệ thống (System Action) | Phản hồi Giao diện (UI Feedback) |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **Xác định Điểm xuất phát** | - Tìm kiếm trên Map Search Box<br>- Hoặc Right-Click (Desktop)<br>- Hoặc Long-Press (Mobile) | 1. Trích xuất tọa độ (lat, lng).<br>2. Gọi API khởi tạo điểm đầu.<br>3. Cập nhật nhãn "Điểm xuất phát:..." trên Top Banner. | Tạo 1 Ghim (Marker) Màu Xanh Blue tại tọa độ được chọn. |
|
||||||
|
| **Xác định Điểm kết thúc** | - Tìm kiếm trên Map Search Box<br>- Hoặc Right-Click (Desktop)<br>- Hoặc Long-Press (Mobile) | 1. Trích xuất tọa độ (lat, lng).<br>2. Gọi API khởi tạo điểm cuối.<br>3. Cập nhật nhãn "Điểm kết thúc:..." trên Top Banner. | Tạo 1 Ghim (Marker) Màu Xanh Green tại tọa độ được chọn. |
|
||||||
|
|
||||||
|
## 3. Kiến Trúc Mã Nguồn Gợi Ý Cho AI Agent (Pseudocode / Implementation Guide)
|
||||||
|
Agent cần triển khai component bản đồ với cấu trúc xử lý sự kiện (Context Menu) như sau:
|
||||||
|
|
||||||
|
### 3.1. Cấu trúc Menu Ngữ Cảnh (Custom Context Menu Component)
|
||||||
|
Khi sự kiện click chuột phải/nhấn giữ xảy ra, lấy ra tọa độ (lat, lng) của điểm chạm và hiển thị menu pop-up tại vị trí con trỏ:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ContextMenuProps {
|
||||||
|
x: number; // Tọa độ pixel trên màn hình
|
||||||
|
y: number;
|
||||||
|
latLng: { lat: number; lng: number };
|
||||||
|
onSelectStart: (latLng: { lat: number; lng: number }) => void;
|
||||||
|
onSelectEnd: (latLng: { lat: number; lng: number }) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2. Thuật toán xử lý ghim (Marker Rendering Logic)
|
||||||
|
Mô tả logic bằng mã giả để Agent tiến hành sinh code xử lý:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Trạng thái lưu trữ tọa độ trên Frontend
|
||||||
|
const [startCoords, setStartCoords] = useState(null);
|
||||||
|
const [endCoords, setEndCoords] = useState(null);
|
||||||
|
|
||||||
|
// Hàm xử lý khi chọn "Bắt đầu từ đây"
|
||||||
|
function handleSetStartPoint(latLng) {
|
||||||
|
// 1. Cập nhật state để render ghim Blue
|
||||||
|
setStartCoords(latLng);
|
||||||
|
|
||||||
|
// 2. Gọi API cập nhật Database thông qua UUID của Tour hiện tại
|
||||||
|
API.updateTourStartPoint(tourId, {
|
||||||
|
latitude: latLng.lat,
|
||||||
|
longitude: latLng.lng,
|
||||||
|
locationType: 'START'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Render Marker Blue lên Bản đồ
|
||||||
|
map.renderMarker({
|
||||||
|
position: latLng,
|
||||||
|
icon: 'blue-pin.png',
|
||||||
|
label: 'S'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hàm xử lý khi chọn "Kết thúc ở đây"
|
||||||
|
function handleSetEndPoint(latLng) {
|
||||||
|
// 1. Cập nhật state để render ghim Green
|
||||||
|
setEndCoords(latLng);
|
||||||
|
|
||||||
|
// 2. Gọi API cập nhật Database
|
||||||
|
API.updateTourEndPoint(tourId, {
|
||||||
|
latitude: latLng.lat,
|
||||||
|
longitude: latLng.lng,
|
||||||
|
locationType: 'END'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Render Marker Green lên Bản đồ
|
||||||
|
map.renderMarker({
|
||||||
|
position: latLng,
|
||||||
|
icon: 'green-pin.png',
|
||||||
|
label: 'E'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, User, Trash2, Shield, ShieldAlert, Lock, Unlock, Loader2, UserPlus } from 'lucide-react';
|
||||||
|
|
||||||
|
interface UserManagementModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen, onClose }) => {
|
||||||
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/users`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Không thể tải danh sách người dùng');
|
||||||
|
const data = await response.json();
|
||||||
|
setUsers(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) fetchUsers();
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleToggleBlock = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
await fetch(`${API_BASE}/api/v1/users/block/${id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||||
|
});
|
||||||
|
fetchUsers();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Lỗi khi thay đổi trạng thái block');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Bạn có chắc chắn muốn xóa người dùng này?')) return;
|
||||||
|
try {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/users/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message);
|
||||||
|
}
|
||||||
|
fetchUsers();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
|
||||||
|
<div className="relative w-full max-w-4xl bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||||
|
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<Shield className="w-6 h-6 text-blue-600" /> Quản lý người dùng
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500">Quản trị viên có quyền thêm, sửa, xóa hoặc khóa tài khoản.</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
|
||||||
|
<X className="w-6 h-6 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="p-4 bg-red-50 text-red-600 rounded-xl font-bold">{error}</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100">
|
||||||
|
<th className="pb-4 font-bold px-2">Người dùng</th>
|
||||||
|
<th className="pb-4 font-bold">Vai trò</th>
|
||||||
|
<th className="pb-4 font-bold">Trạng thái</th>
|
||||||
|
<th className="pb-4 font-bold text-right">Thao tác</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-50">
|
||||||
|
{users.map(u => (
|
||||||
|
<tr key={u.id} className="group hover:bg-gray-50/50 transition-colors">
|
||||||
|
<td className="py-4 px-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
|
||||||
|
{u.name?.charAt(0) || <User className="w-5 h-5" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-gray-900">{u.name || 'N/A'}</div>
|
||||||
|
<div className="text-xs text-gray-400">{u.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
{u.isAdmin ? (
|
||||||
|
<span className="px-2 py-1 bg-purple-50 text-purple-600 text-[10px] font-black rounded-md border border-purple-100">ADMIN</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-1 bg-gray-50 text-gray-500 text-[10px] font-black rounded-md border border-gray-100">USER</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
{u.isBlocked ? (
|
||||||
|
<span className="flex items-center gap-1 text-red-500 text-xs font-bold"><ShieldAlert className="w-3 h-3" /> Đã khóa</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-green-500 text-xs font-bold">Đang hoạt động</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleBlock(u.id)}
|
||||||
|
className={`p-2 rounded-xl transition-all ${u.isBlocked ? 'bg-green-50 text-green-600 hover:bg-green-100' : 'bg-amber-50 text-amber-600 hover:bg-amber-100'}`}
|
||||||
|
title={u.isBlocked ? 'Mở khóa' : 'Khóa tài khoản'}
|
||||||
|
>
|
||||||
|
{u.isBlocked ? <Unlock className="w-4 h-4" /> : <Lock className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(u.id)}
|
||||||
|
className="p-2 bg-red-50 text-red-600 rounded-xl hover:bg-red-100 transition-all"
|
||||||
|
title="Xóa người dùng"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminGuard implements CanActivate {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
// Lưu ý: request.user thường được đính kèm bởi một AuthGuard (JWT/Passport) chạy trước đó.
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
if (!user || !user.id) {
|
||||||
|
throw new ForbiddenException('Yêu cầu xác thực không hợp lệ. Vui lòng đăng nhập.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kiểm tra trực tiếp từ database để đảm bảo quyền isAdmin là chính xác nhất cho các tác vụ nhạy cảm
|
||||||
|
const dbUser = await this.prisma.user.findUnique({
|
||||||
|
where: { id: user.id },
|
||||||
|
select: { isAdmin: true, isBlocked: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dbUser || !dbUser.isAdmin || dbUser.isBlocked) {
|
||||||
|
throw new ForbiddenException('Truy cập bị từ chối. Bạn không có quyền quản trị viên hệ thống.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
// Types for notification modal
|
||||||
|
export interface NotificationModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
type?: 'info' | 'success' | 'warning' | 'error';
|
||||||
|
confirmButtonText?: string;
|
||||||
|
cancelButtonText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon components for different types
|
||||||
|
const Icons = {
|
||||||
|
info: (props: React.SVGProps<SVGSVGElement>) => (
|
||||||
|
<svg {...props} viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<path d="M12 16v-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<path d="M12 8h.01" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
success: (props: React.SVGProps<SVGSVGElement>) => (
|
||||||
|
<svg {...props} viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<path d="M8 12l2.5 2.5L15.5 9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
warning: (props: React.SVGProps<SVGSVGElement>) => (
|
||||||
|
<svg {...props} viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<circle cx="12" cy="17" r="1" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
error: (props: React.SVGProps<SVGSVGElement>) => (
|
||||||
|
<svg {...props} viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default props
|
||||||
|
const defaultProps: Partial<NotificationModalProps> = {
|
||||||
|
title: 'Thông báo',
|
||||||
|
type: 'info',
|
||||||
|
confirmButtonText: 'OK',
|
||||||
|
cancelButtonText: 'Hủy',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NotificationModal: React.FC<NotificationModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
title = defaultProps.title,
|
||||||
|
message,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
type = defaultProps.type,
|
||||||
|
confirmButtonText = defaultProps.confirmButtonText,
|
||||||
|
cancelButtonText = defaultProps.cancelButtonText,
|
||||||
|
}) => {
|
||||||
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
|
|
||||||
|
// Get colors based on type
|
||||||
|
const getTypeStyles = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'success':
|
||||||
|
return { bg: '#d4edda', border: '#c3e6cb', text: '#155724' };
|
||||||
|
case 'warning':
|
||||||
|
return { bg: '#fff3cd', border: '#ffeeba', text: '#856404' };
|
||||||
|
case 'error':
|
||||||
|
return { bg: '#f8d7da', border: '#f5c6cb', text: '#721c24' };
|
||||||
|
default:
|
||||||
|
return { bg: '#e2e3e5', border: '#d6d8db', text: '#383d41' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = getTypeStyles();
|
||||||
|
|
||||||
|
// Animation classes based on state
|
||||||
|
const getAnimationClass = () => {
|
||||||
|
if (!isOpen) return 'opacity-0 translate-y-4';
|
||||||
|
if (isAnimating && onCancel) return 'animate-fade-out';
|
||||||
|
return 'animate-fade-in';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle confirm click
|
||||||
|
const handleConfirm = () => {
|
||||||
|
setIsAnimating(true);
|
||||||
|
onConfirm?.();
|
||||||
|
setTimeout(() => setIsAnimating(false), 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle cancel click
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsAnimating(true);
|
||||||
|
onCancel?.();
|
||||||
|
setTimeout(() => setIsAnimating(false), 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[100] p-4">
|
||||||
|
<div
|
||||||
|
className={`bg-white rounded-lg shadow-xl max-w-md w-full transform transition-all duration-300 ${getAnimationClass()}`}
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="modal-title"
|
||||||
|
aria-describedby="modal-message"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={`p-6 border-b ${styles.border}`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{type === 'success' && <Icons.success className="w-5 h-5 text-green-600" />}
|
||||||
|
{type === 'warning' && <Icons.warning className="w-5 h-5 text-yellow-600" />}
|
||||||
|
{type === 'error' && <Icons.error className="w-5 h-5 text-red-600" />}
|
||||||
|
{type === 'info' && <Icons.info className="w-5 h-5 text-blue-600" />}
|
||||||
|
<h2 id="modal-title" className={`text-xl font-semibold ${styles.text}`}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="p-6">
|
||||||
|
<p id="modal-message" className="text-gray-700 leading-relaxed">{message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className={`px-6 py-4 flex justify-end gap-3 border-t ${styles.border}`}>
|
||||||
|
{onCancel && (
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
{cancelButtonText}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onConfirm && (
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className={`px-4 py-2 text-white rounded-md font-medium transition-colors ${
|
||||||
|
type === 'error'
|
||||||
|
? 'bg-red-600 hover:bg-red-700'
|
||||||
|
: 'bg-blue-600 hover:bg-blue-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmButtonText}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for easy usage without props management
|
||||||
|
export const useNotificationModal = () => {
|
||||||
|
const [modalState, setModalState] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
type?: 'info' | 'success' | 'warning' | 'error';
|
||||||
|
onConfirm?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const openModal = (
|
||||||
|
title: string,
|
||||||
|
message: string,
|
||||||
|
type: 'info' | 'success' | 'warning' | 'error' = 'info',
|
||||||
|
onConfirm?: () => void,
|
||||||
|
onCancel?: () => void,
|
||||||
|
) => {
|
||||||
|
setModalState({
|
||||||
|
isOpen: true,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-close after 5 seconds if no confirm action
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (onCancel) {
|
||||||
|
setModalState((prev) => ({ ...prev, isOpen: false }));
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setModalState((prev) => prev ? { ...prev, isOpen: false } : null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
modalState,
|
||||||
|
openModal,
|
||||||
|
closeModal,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationModal;
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
import React from 'react';
|
||||||
|
export declare const AddLocationModal: ({ isOpen, onClose, tourId, initialLegId, editingLocation }: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
tourId: string;
|
||||||
|
initialLegId?: string;
|
||||||
|
editingLocation?: any;
|
||||||
|
}) => React.JSX.Element;
|
||||||
Vendored
+146
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+31
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
interface AddMemberModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
tourId: string;
|
||||||
|
participants?: Array<{
|
||||||
|
userId: string;
|
||||||
|
role: string;
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
joinRequests?: Array<{
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
status: string;
|
||||||
|
requestedById: string;
|
||||||
|
}>;
|
||||||
|
onRemoveMember?: (userId: string) => Promise<void>;
|
||||||
|
onMemberAdded?: () => void;
|
||||||
|
userRole?: string;
|
||||||
|
}
|
||||||
|
export declare const AddMemberModal: React.FC<AddMemberModalProps>;
|
||||||
|
export {};
|
||||||
Vendored
+154
@@ -0,0 +1,154 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { X, UserPlus, Loader2, Shield, Trash2, Clock } from 'lucide-react';
|
||||||
|
export const AddMemberModal = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole }) => {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState(null);
|
||||||
|
const [role, setRole] = useState('MEMBER');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [fetchError, setFetchError] = useState('');
|
||||||
|
const [submitError, setSubmitError] = useState('');
|
||||||
|
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
||||||
|
const [confirmTarget, setConfirmTarget] = useState(null);
|
||||||
|
const [actionLoading, setActionLoading] = useState(null);
|
||||||
|
const participantIds = useMemo(() => new Set(participants.map((p) => p.userId)), [participants]);
|
||||||
|
const requestUserIds = useMemo(() => new Set(joinRequests.map((r) => r.userId)), [joinRequests]);
|
||||||
|
const visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]);
|
||||||
|
const canCreateDirectly = !userRole || userRole === 'OWNER' || userRole === 'MANAGER';
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setFetchError('');
|
||||||
|
try {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/users?q=${encodeURIComponent(query)}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||||
|
});
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error('Không thể tải danh sách người dùng');
|
||||||
|
const data = await res.json();
|
||||||
|
setUsers(Array.isArray(data) ? data : []);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
setFetchError(err.message || 'Không thể tải danh sách người dùng');
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen)
|
||||||
|
return;
|
||||||
|
fetchUsers();
|
||||||
|
}, [isOpen]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setQuery('');
|
||||||
|
setSelectedUser(null);
|
||||||
|
setRole('MEMBER');
|
||||||
|
setFetchError('');
|
||||||
|
setSubmitError('');
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
const handleRemove = async (userId, memberName) => {
|
||||||
|
if (!onRemoveMember)
|
||||||
|
return;
|
||||||
|
setConfirmTarget({ userId, name: memberName });
|
||||||
|
setIsConfirmOpen(true);
|
||||||
|
};
|
||||||
|
const confirmRemove = async () => {
|
||||||
|
if (!confirmTarget || !onRemoveMember)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
await onRemoveMember(confirmTarget.userId);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
setSubmitError(err.message || 'Không thể xóa thành viên');
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setIsConfirmOpen(false);
|
||||||
|
setConfirmTarget(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleRequestAction = async (reqId, action, userName) => {
|
||||||
|
if (!onMemberAdded)
|
||||||
|
return;
|
||||||
|
setActionLoading(reqId);
|
||||||
|
try {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const endpoint = action === 'accept'
|
||||||
|
? `${API_BASE}/api/v1/tours/${tourId}/join-requests/${reqId}/accept`
|
||||||
|
: `${API_BASE}/api/v1/tours/${tourId}/join-requests/${reqId}/reject`;
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.message || data.error || (action === 'accept' ? 'Không thể chấp nhận' : 'Không thể từ chối'));
|
||||||
|
}
|
||||||
|
await onMemberAdded();
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
alert(err.message || 'Thao tác thất bại');
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!selectedUser)
|
||||||
|
return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setSubmitError('');
|
||||||
|
try {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const endpoint = canCreateDirectly ? `/api/v1/tours/${tourId}/members` : `/api/v1/tours/${tourId}/join-requests`;
|
||||||
|
const body = canCreateDirectly
|
||||||
|
? { userId: selectedUser, role }
|
||||||
|
: { userId: selectedUser };
|
||||||
|
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || data.error || 'Thao tác thất bại');
|
||||||
|
}
|
||||||
|
await onMemberAdded?.();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
setSubmitError(err.message || 'Thao tác thất bại');
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!isOpen)
|
||||||
|
return null;
|
||||||
|
return (_jsxs("div", { className: "fixed inset-0 z-[2000] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/60 backdrop-blur-sm", onClick: onClose }), _jsxs("div", { className: "relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]", children: [_jsxs("div", { className: "p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50", children: [_jsxs("div", { children: [_jsxs("h2", { className: "text-lg font-bold text-gray-900 flex items-center gap-2", children: [_jsx(UserPlus, { className: "w-5 h-5 text-blue-600" }), " ", canCreateDirectly ? 'Thêm thành viên' : 'Mời tham gia tour'] }), _jsx("p", { className: "text-xs text-gray-500", children: canCreateDirectly ? 'Chọn người dùng và phân quyền cho tour này.' : 'Mời sẽ được gửi và chờ người quản lý phê duyệt.' })] }), _jsx("button", { onClick: onClose, className: "p-2 hover:bg-gray-200 rounded-full transition-colors", children: _jsx(X, { className: "w-5 h-5 text-gray-400" }) })] }), _jsxs("div", { className: "p-5 space-y-4", children: [_jsxs("div", { children: [_jsxs("p", { className: "text-[11px] font-bold text-gray-500 mb-2", children: ["Th\u00E0nh vi\u00EAn c\u1EE7a tour (", participants.length, ")"] }), _jsxs("div", { className: "flex flex-wrap gap-3", children: [participants.map((p) => {
|
||||||
|
const rawToken = localStorage.getItem('token');
|
||||||
|
let currentUserId = null;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob((rawToken || '').split('.')[1]));
|
||||||
|
currentUserId = payload.sub;
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
currentUserId = null;
|
||||||
|
}
|
||||||
|
const isCurrentUser = currentUserId && p.userId === currentUserId;
|
||||||
|
const isOwner = p.role === 'OWNER';
|
||||||
|
const canRemove = onRemoveMember && !isCurrentUser && !isOwner;
|
||||||
|
return (_jsxs("div", { className: "flex flex-col items-center gap-1", children: [_jsxs("div", { className: "relative", children: [_jsx("div", { className: "w-10 h-10 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden", children: p.user?.name?.charAt(0) || '?' }), canRemove && (_jsx("button", { onClick: () => handleRemove(p.userId, p.user?.name || p.userId), className: "absolute -top-1 -right-1 w-4 h-4 bg-gray-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white", "aria-label": "Remove item", children: _jsx(Trash2, { size: 10 }) }))] }), _jsx("span", { className: "text-[10px] font-semibold text-gray-700 max-w-[72px] truncate", children: p.user?.name || p.userId })] }, p.userId));
|
||||||
|
}), participants.length === 0 && (_jsx("span", { className: "text-xs text-gray-400", children: "Ch\u01B0a c\u00F3 th\u00E0nh vi\u00EAn n\u00E0o" }))] })] }), joinRequests.length > 0 && (_jsxs("div", { children: [_jsxs("p", { className: "text-[11px] font-bold text-gray-500 mb-2 flex items-center gap-1", children: [_jsx(Clock, { className: "w-3 h-3 text-amber-500" }), " \u0110ang ch\u1EDD ph\u00EA duy\u1EC7t (", joinRequests.length, ")"] }), _jsx("div", { className: "flex flex-wrap gap-3", children: joinRequests.map((req) => (_jsxs("div", { className: "flex flex-col items-center gap-1 relative", children: [_jsx("div", { className: "w-10 h-10 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-600 font-bold text-sm overflow-hidden", children: req.user?.name?.charAt(0) || '?' }), _jsxs("div", { className: "absolute -top-1 -right-1 flex", children: [_jsx("button", { type: "button", disabled: actionLoading === req.id, onClick: () => handleRequestAction(req.id, 'accept', req.user?.name || req.userId), className: "w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50", "aria-label": "Accept", children: "+" }), _jsx("button", { type: "button", disabled: actionLoading === req.id, onClick: () => handleRequestAction(req.id, 'reject', req.user?.name || req.userId), className: "w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1", "aria-label": "Reject", children: "x" })] }), _jsx("span", { className: "text-[10px] font-semibold text-gray-700 max-w-[72px] truncate", children: req.user?.name || req.userId }), _jsx("span", { className: "text-[9px] font-bold text-amber-600 bg-amber-100 px-1.5 py-0.5 rounded-full border border-amber-200", children: "PENDING" })] }, req.id))) })] })), canCreateDirectly && (_jsxs("div", { className: "space-y-1.5", children: [_jsx("label", { className: "text-xs font-bold text-gray-700 ml-1", children: "Ph\u00E2n quy\u1EC1n" }), _jsxs("select", { className: "w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm", value: role, onChange: (e) => setRole(e.target.value), children: [_jsx("option", { value: "OWNER", children: "OWNER" }), _jsx("option", { value: "MANAGER", children: "MANAGER" }), _jsx("option", { value: "MEMBER", children: "MEMBER" }), _jsx("option", { value: "MEMBER_NO_FINANCE", children: "MEMBER_NO_FINANCE" }), _jsx("option", { value: "VIEWER_ONLY", children: "VIEWER_ONLY" })] })] })), _jsxs("div", { className: "space-y-2", children: [fetchError && (_jsx("div", { className: "p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100", children: fetchError })), submitError && (_jsx("div", { className: "p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100", children: submitError })), loading ? (_jsx("div", { className: "flex justify-center py-10", children: _jsx(Loader2, { className: "w-8 h-8 animate-spin text-blue-600" }) })) : (_jsxs("div", { className: "space-y-2 max-h-[40vh] overflow-y-auto pr-1", children: [visibleUsers.map((u) => {
|
||||||
|
const isSelected = selectedUser === u.id;
|
||||||
|
return (_jsxs("button", { onClick: () => setSelectedUser(u.id), disabled: requestUserIds.has(u.id), className: `w-full flex items-center gap-3 p-3 rounded-2xl border transition-all ${isSelected ? 'border-blue-500 bg-blue-50/60' : 'border-gray-100 hover:border-blue-200'} ${requestUserIds.has(u.id) ? 'opacity-60' : ''}`, children: [_jsx("div", { className: "w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold", children: u.name?.charAt(0) || '?' }), _jsxs("div", { className: "flex-1 text-left", children: [_jsx("div", { className: "text-sm font-bold text-gray-900", children: u.name || 'Chưa đặt tên' }), _jsx("div", { className: "text-[11px] text-gray-500", children: u.email }), _jsxs("div", { className: "text-[11px] text-gray-400", children: [u.phone || '', " ", u.address ? `• ${u.address}` : ''] })] }), _jsxs("div", { className: "flex items-center gap-1 text-[10px] font-bold text-gray-500", children: [u.isAdmin ? (_jsx("span", { className: "px-2 py-1 bg-purple-50 text-purple-600 rounded-md border border-purple-100", children: "ADMIN" })) : (_jsx("span", { className: "px-2 py-1 bg-gray-50 text-gray-500 rounded-md border border-gray-100", children: "USER" })), isSelected && _jsx(Shield, { className: "w-3 h-3 text-blue-600" })] })] }, u.id));
|
||||||
|
}), !loading && visibleUsers.length === 0 && (_jsx("div", { className: "text-center py-8 text-sm text-gray-500", children: "Kh\u00F4ng t\u00ECm th\u1EA5y ng\u01B0\u1EDDi d\u00F9ng ph\u00F9 h\u1EE3p" }))] }))] })] }), _jsxs("div", { className: "p-5 border-t border-gray-100 flex justify-end gap-2", children: [_jsx("button", { onClick: onClose, className: "px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors", children: "H\u1EE7y" }), _jsx("button", { disabled: !selectedUser || submitting, onClick: handleAdd, className: "px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-xl text-sm font-bold transition-all", children: submitting ? 'Đang xử lý...' : canCreateDirectly ? 'Thêm vào tour' : 'Gửi lời mời' })] })] }), isConfirmOpen && (_jsxs("div", { className: "fixed inset-0 z-[2100] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/70 backdrop-blur-sm", onClick: () => setIsConfirmOpen(false) }), _jsxs("div", { className: "relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5", children: [_jsx("h3", { className: "text-base font-bold text-gray-900", children: "X\u00E1c nh\u1EADn x\u00F3a th\u00E0nh vi\u00EAn" }), _jsxs("p", { className: "mt-2 text-sm text-gray-600", children: ["B\u1EA1n c\u00F3 ch\u1EAFc mu\u1ED1n x\u00F3a ", _jsx("span", { className: "font-semibold text-gray-800", children: confirmTarget?.name }), " kh\u1ECFi tour n\u00E0y?"] }), _jsxs("div", { className: "mt-4 flex justify-end gap-2", children: [_jsx("button", { onClick: () => setIsConfirmOpen(false), className: "px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors", children: "H\u1EE7y" }), _jsx("button", { onClick: confirmRemove, className: "px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors", children: "X\u00F3a" })] })] })] }))] }));
|
||||||
|
};
|
||||||
|
//# sourceMappingURL=AddMemberModal.js.map
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+43
-11
@@ -1,15 +1,47 @@
|
|||||||
"use strict";
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
import { useState, useEffect } from 'react';
|
||||||
const jsx_runtime_1 = require("react/jsx-runtime");
|
import { LandingPage } from './LandingPage.js';
|
||||||
const react_1 = require("react");
|
import { TourDetailPage } from './TourDetailPage.js';
|
||||||
const LandingPage_1 = require("./LandingPage");
|
import { ExploreMap } from './ExploreMap.js';
|
||||||
const TourDetailPage_1 = require("./TourDetailPage");
|
import { SignupPage } from './SignupPage.js';
|
||||||
|
import { useTourStore } from './useTourStore.js';
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const [isStarted, setIsStarted] = (0, react_1.useState)(false);
|
const [view, setView] = useState('landing');
|
||||||
const handleContinue = () => {
|
const [isInitialSetup, setIsInitialSetup] = useState(false);
|
||||||
setIsStarted(true);
|
const [user, setUser] = useState(null);
|
||||||
|
const [isUserLoaded, setIsUserLoaded] = useState(false);
|
||||||
|
const fetchTour = useTourStore(state => state.fetchTour);
|
||||||
|
useEffect(() => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const savedUser = localStorage.getItem('user');
|
||||||
|
if (savedUser) {
|
||||||
|
const parsedUser = JSON.parse(savedUser);
|
||||||
|
setUser(parsedUser);
|
||||||
|
}
|
||||||
|
setIsUserLoaded(true);
|
||||||
|
fetch(`${API_BASE}/api/v1/auth/status`)
|
||||||
|
.then(res => res.ok ? res.json() : Promise.reject())
|
||||||
|
.then(data => setIsInitialSetup(!!data.isInitialSetup))
|
||||||
|
.catch(() => setIsInitialSetup(false));
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (isUserLoaded && user && view === 'landing') {
|
||||||
|
setView('explore');
|
||||||
|
}
|
||||||
|
}, [isUserLoaded, user, view]);
|
||||||
|
const handleLoginSuccess = (userData) => {
|
||||||
|
setUser(userData);
|
||||||
};
|
};
|
||||||
return ((0, jsx_runtime_1.jsx)("div", { className: "app-container", children: !isStarted ? ((0, jsx_runtime_1.jsx)(LandingPage_1.LandingPage, { onContinue: handleContinue })) : ((0, jsx_runtime_1.jsx)(TourDetailPage_1.TourDetailPage, {})) }));
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
setUser(null);
|
||||||
|
setView('landing');
|
||||||
};
|
};
|
||||||
exports.default = App;
|
return (_jsxs("div", { className: "app-container", children: [view === 'landing' && (_jsx(LandingPage, { isInitialSetup: isInitialSetup, onContinue: () => setView('explore'), onGoToSignup: () => setView('signup'), onGoToMap: () => setView('explore'), onLoginSuccess: handleLoginSuccess })), view === 'signup' && (_jsx(SignupPage, { onBack: () => setView('landing'), onSuccess: () => setView('landing') })), view === 'explore' && (_jsx(ExploreMap, { onBack: () => setView('landing'), onLogout: user ? handleLogout : undefined, user: user, onViewTour: (id) => {
|
||||||
|
fetchTour(id);
|
||||||
|
setView('detail');
|
||||||
|
} })), view === 'detail' && (_jsx(TourDetailPage, { onBack: () => setView('explore') }))] }));
|
||||||
|
};
|
||||||
|
export default App;
|
||||||
//# sourceMappingURL=App.js.map
|
//# sourceMappingURL=App.js.map
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"App.js","sourceRoot":"","sources":["../App.tsx"],"names":[],"mappings":";;;AAAA,iCAAwC;AACxC,+CAA4C;AAC5C,qDAAkD;AAElD,MAAM,GAAG,GAAG,GAAG,EAAE;IAGf,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,GAAG,IAAA,gBAAQ,EAAC,KAAK,CAAC,CAAC;IAElD,MAAM,cAAc,GAAG,GAAG,EAAE;QAC1B,YAAY,CAAC,IAAI,CAAC,CAAC;IACrB,CAAC,CAAC;IAEF,OAAO,CACL,gCAAK,SAAS,EAAC,eAAe,YAC3B,CAAC,SAAS,CAAC,CAAC,CAAC,CACZ,uBAAC,yBAAW,IAAC,UAAU,EAAE,cAAc,GAAI,CAC5C,CAAC,CAAC,CAAC,CACF,uBAAC,+BAAc,KAAG,CACnB,GACG,CACP,CAAC;AACJ,CAAC,CAAC;AAEF,kBAAe,GAAG,CAAC"}
|
{"version":3,"file":"App.js","sourceRoot":"","sources":["../App.tsx"],"names":[],"mappings":"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"}
|
||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react';
|
||||||
|
interface ConfirmModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
export declare const ConfirmModal: React.FC<ConfirmModalProps>;
|
||||||
|
export {};
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
export const ConfirmModal = ({ isOpen, title = 'Xác nhận', message, confirmText = 'Xác nhận', cancelText = 'Hủy', onConfirm, onCancel, }) => {
|
||||||
|
if (!isOpen)
|
||||||
|
return null;
|
||||||
|
return (_jsxs("div", { className: "fixed inset-0 z-[2200] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/70 backdrop-blur-sm", onClick: onCancel }), _jsxs("div", { className: "relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5", children: [_jsx("h3", { className: "text-base font-bold text-gray-900", children: title }), _jsx("p", { className: "mt-2 text-sm text-gray-600", children: message }), _jsxs("div", { className: "mt-4 flex justify-end gap-2", children: [_jsx("button", { onClick: onCancel, className: "px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors", children: cancelText }), _jsx("button", { onClick: onConfirm, className: "px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors", children: confirmText })] })] })] }));
|
||||||
|
};
|
||||||
|
//# sourceMappingURL=ConfirmModal.js.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"ConfirmModal.js","sourceRoot":"","sources":["../ConfirmModal.tsx"],"names":[],"mappings":";AAaA,MAAM,CAAC,MAAM,YAAY,GAAgC,CAAC,EACxD,MAAM,EACN,KAAK,GAAG,UAAU,EAClB,OAAO,EACP,WAAW,GAAG,UAAU,EACxB,UAAU,GAAG,KAAK,EAClB,SAAS,EACT,QAAQ,GACT,EAAE,EAAE;IACH,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAC;IAEzB,OAAO,CACL,eAAK,SAAS,EAAC,6DAA6D,aAC1E,cAAK,SAAS,EAAC,kDAAkD,EAAC,OAAO,EAAE,QAAQ,GAAI,EACvF,eAAK,SAAS,EAAC,8DAA8D,aAC3E,aAAI,SAAS,EAAC,mCAAmC,YAAE,KAAK,GAAM,EAC9D,YAAG,SAAS,EAAC,4BAA4B,YAAE,OAAO,GAAK,EACvD,eAAK,SAAS,EAAC,6BAA6B,aAC1C,iBAAQ,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAC,0FAA0F,YAC5H,UAAU,GACJ,EACT,iBAAQ,OAAO,EAAE,SAAS,EAAE,SAAS,EAAC,iGAAiG,YACpI,WAAW,GACL,IACL,IACF,IACF,CACP,CAAC;AACJ,CAAC,CAAC"}
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
import React from 'react';
|
||||||
|
export declare const CreateTourModal: ({ isOpen, onClose, onSuccess }: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: (tour: any) => void;
|
||||||
|
}) => React.JSX.Element;
|
||||||
Vendored
+66
@@ -0,0 +1,66 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
import { useTourStore } from './useTourStore.js';
|
||||||
|
export const CreateTourModal = ({ isOpen, onClose, onSuccess }) => {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [endDate, setEndDate] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const createTour = useTourStore((state) => state.createTour);
|
||||||
|
const [members, setMembers] = useState([]);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [results, setResults] = useState([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
if (!isOpen)
|
||||||
|
return null;
|
||||||
|
const searchUsers = async (value) => {
|
||||||
|
setQuery(value);
|
||||||
|
if (!value.trim()) {
|
||||||
|
setResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/users?q=${encodeURIComponent(value)}`, {
|
||||||
|
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
|
||||||
|
});
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error('Không thể tải người dùng');
|
||||||
|
const data = await res.json();
|
||||||
|
setResults(Array.isArray(data) ? data : []);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
setResults([]);
|
||||||
|
setError(e.message || 'Không thể tải người dùng');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const confirmAddMember = (user) => {
|
||||||
|
setMembers((prev) => (prev.some((m) => m.id === user.id) ? prev : [...prev, user]));
|
||||||
|
setQuery('');
|
||||||
|
setResults([]);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
const removeMember = (userId) => {
|
||||||
|
setMembers((prev) => prev.filter((m) => m.id !== userId));
|
||||||
|
};
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const memberIds = members.map((m) => m.id);
|
||||||
|
const tour = await createTour({ title, startDate, endDate, memberIds });
|
||||||
|
onSuccess(tour);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
setError(e.message || 'Lỗi khi tạo tour');
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (_jsxs("div", { className: "fixed inset-0 z-[2000] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/60 backdrop-blur-sm", onClick: onClose }), _jsxs("div", { className: "relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden p-6", children: [_jsxs("div", { className: "flex justify-between items-center mb-4", children: [_jsx("h2", { className: "text-xl font-bold text-gray-900", children: "T\u1EA1o Tour m\u1EDBi" }), _jsx("button", { onClick: onClose, className: "p-2 hover:bg-gray-100 rounded-full transition-colors", children: "\u2715" })] }), _jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [_jsxs("div", { children: [_jsx("label", { className: "block text-sm font-bold text-gray-700 mb-1", children: "T\u00EAn Tour" }), _jsx("input", { required: true, className: "w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500", value: title, onChange: (e) => setTitle(e.target.value), placeholder: "VD: Kh\u00E1m ph\u00E1 \u0110\u00E0 L\u1EA1t" })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { children: [_jsx("label", { className: "block text-sm font-bold text-gray-700 mb-1", children: "B\u1EAFt \u0111\u1EA7u" }), _jsx("input", { type: "date", className: "w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500", value: startDate, onChange: (e) => setStartDate(e.target.value) })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-sm font-bold text-gray-700 mb-1", children: "K\u1EBFt th\u00FAc" }), _jsx("input", { type: "date", className: "w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500", value: endDate, onChange: (e) => setEndDate(e.target.value) })] })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-sm font-bold text-gray-700 mb-2", children: "Th\u00E0nh vi\u00EAn tham gia" }), _jsxs("div", { className: "flex flex-wrap gap-3", children: [members.map((m) => (_jsxs("div", { className: "relative", children: [_jsx("div", { className: "w-12 h-12 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden", children: m.name }), _jsx("button", { type: "button", onClick: () => removeMember(m.id), className: "absolute -top-1 -right-1 w-5 h-5 rounded-full bg-gray-500 hover:bg-red-600 text-white flex items-center justify-center transition-colors", "aria-label": "Remove item", children: _jsx(Trash2, { size: 12 }) }), _jsx("div", { className: "text-[10px] text-center mt-1 max-w-[70px] truncate", children: m.name })] }, m.id))), _jsxs("div", { className: "relative", children: [_jsx("input", { className: "w-36 px-2 py-1 text-sm border border-gray-200 rounded-lg outline-none", placeholder: "T\u00ECm email...", value: query, onChange: (e) => searchUsers(e.target.value) }), results.length > 0 && (_jsx("div", { className: "absolute top-full left-0 right-0 mt-2 bg-white border border-gray-100 rounded-xl shadow-lg z-10 max-h-60 overflow-y-auto", children: results.map((u) => (_jsxs("button", { type: "button", onClick: () => confirmAddMember(u), className: "w-full text-left px-3 py-2 text-sm hover:bg-blue-50", children: [_jsx("span", { className: "font-bold text-gray-900", children: u.name }), _jsx("span", { className: "block text-xs text-gray-500", children: u.email })] }, u.id))) }))] })] }), error && _jsx("p", { className: "text-xs text-red-600 mt-2", children: error })] }), _jsx("button", { type: "submit", disabled: isLoading, className: "w-full py-3 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl shadow-lg transition-all flex items-center justify-center gap-2", children: isLoading ? 'Đang tạo...' : 'Xác nhận tạo Tour' })] })] })] }));
|
||||||
|
};
|
||||||
|
//# sourceMappingURL=CreateTourModal.js.map
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+11
-15
@@ -1,16 +1,13 @@
|
|||||||
"use strict";
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
import { useState, useMemo } from 'react';
|
||||||
exports.ExpenseManager = void 0;
|
import { Wallet, Users, Info } from 'lucide-react';
|
||||||
const jsx_runtime_1 = require("react/jsx-runtime");
|
import { useTourStore } from './useTourStore.js';
|
||||||
const react_1 = require("react");
|
export const ExpenseManager = () => {
|
||||||
const lucide_react_1 = require("lucide-react");
|
const { legs } = useTourStore();
|
||||||
const useTourStore_1 = require("./useTourStore");
|
const [adults, setAdults] = useState(2);
|
||||||
const ExpenseManager = () => {
|
const [children, setChildren] = useState(1);
|
||||||
const { legs } = (0, useTourStore_1.useTourStore)();
|
const [discount, setDiscount] = useState(30);
|
||||||
const [adults, setAdults] = (0, react_1.useState)(2);
|
const totals = useMemo(() => {
|
||||||
const [children, setChildren] = (0, react_1.useState)(1);
|
|
||||||
const [discount, setDiscount] = (0, react_1.useState)(30);
|
|
||||||
const totals = (0, react_1.useMemo)(() => {
|
|
||||||
const totalAmount = legs.reduce((acc, leg) => acc + leg.expenses.reduce((lAcc, exp) => lAcc + Number(exp.amount), 0), 0);
|
const totalAmount = legs.reduce((acc, leg) => acc + leg.expenses.reduce((lAcc, exp) => lAcc + Number(exp.amount), 0), 0);
|
||||||
const childRateFactor = 1 - (discount / 100);
|
const childRateFactor = 1 - (discount / 100);
|
||||||
const weightedCount = adults + (children * childRateFactor);
|
const weightedCount = adults + (children * childRateFactor);
|
||||||
@@ -22,7 +19,6 @@ const ExpenseManager = () => {
|
|||||||
childPrice: Math.round(childPrice)
|
childPrice: Math.round(childPrice)
|
||||||
};
|
};
|
||||||
}, [legs, adults, children, discount]);
|
}, [legs, adults, children, discount]);
|
||||||
return ((0, jsx_runtime_1.jsxs)("div", { className: "space-y-6 animate-in fade-in slide-in-from-bottom-4", children: [(0, jsx_runtime_1.jsxs)("div", { className: "bg-white p-6 rounded-2xl border border-gray-100 shadow-sm", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2 mb-6 text-blue-600", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Users, { className: "w-5 h-5" }), (0, jsx_runtime_1.jsx)("h3", { className: "font-bold", children: "C\u1EA5u h\u00ECnh th\u00E0nh vi\u00EAn" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "grid grid-cols-3 gap-4", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { className: "text-xs text-gray-400 block mb-1", children: "Ng\u01B0\u1EDDi l\u1EDBn" }), (0, jsx_runtime_1.jsx)("input", { type: "number", value: adults, onChange: e => setAdults(Number(e.target.value)), className: "w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" })] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { className: "text-xs text-gray-400 block mb-1", children: "Tr\u1EBB em" }), (0, jsx_runtime_1.jsx)("input", { type: "number", value: children, onChange: e => setChildren(Number(e.target.value)), className: "w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" })] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { className: "text-xs text-gray-400 block mb-1", children: "Gi\u1EA3m tr\u1EBB em (%)" }), (0, jsx_runtime_1.jsx)("input", { type: "number", value: discount, onChange: e => setDiscount(Number(e.target.value)), className: "w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" })] })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "bg-blue-600 rounded-2xl p-6 text-white shadow-lg shadow-blue-200", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex justify-between items-start mb-8", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("p", { className: "text-blue-100 text-sm", children: "T\u1ED5ng chi ph\u00ED chuy\u1EBFn \u0111i" }), (0, jsx_runtime_1.jsxs)("h2", { className: "text-3xl font-bold mt-1", children: [totals.total.toLocaleString(), " VND"] })] }), (0, jsx_runtime_1.jsx)(lucide_react_1.Wallet, { className: "w-8 h-8 opacity-20" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "grid grid-cols-2 gap-4 border-t border-blue-500 pt-6", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("p", { className: "text-blue-100 text-xs uppercase tracking-wider font-semibold", children: "M\u1ED7i ng\u01B0\u1EDDi l\u1EDBn" }), (0, jsx_runtime_1.jsxs)("p", { className: "text-xl font-bold", children: [totals.adultPrice.toLocaleString(), "\u0111"] })] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsxs)("p", { className: "text-blue-100 text-xs uppercase tracking-wider font-semibold", children: ["M\u1ED7i tr\u1EBB em (-", discount, "%)"] }), (0, jsx_runtime_1.jsxs)("p", { className: "text-xl font-bold", children: [totals.childPrice.toLocaleString(), "\u0111"] })] })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2 text-gray-400 text-xs px-2", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Info, { className: "w-4 h-4" }), (0, jsx_runtime_1.jsx)("p", { children: "Chi ph\u00ED \u0111\u01B0\u1EE3c t\u1EF1 \u0111\u1ED9ng t\u00EDnh to\u00E1n d\u1EF1a tr\u00EAn h\u00F3a \u0111\u01A1n c\u1EE7a c\u00E1c ch\u1EB7ng." })] })] }));
|
return (_jsxs("div", { className: "space-y-6 animate-in fade-in slide-in-from-bottom-4", children: [_jsxs("div", { className: "bg-white p-6 rounded-2xl border border-gray-100 shadow-sm", children: [_jsxs("div", { className: "flex items-center gap-2 mb-6 text-blue-600", children: [_jsx(Users, { className: "w-5 h-5" }), _jsx("h3", { className: "font-bold", children: "C\u1EA5u h\u00ECnh th\u00E0nh vi\u00EAn" })] }), _jsxs("div", { className: "grid grid-cols-3 gap-4", children: [_jsxs("div", { children: [_jsx("label", { className: "text-xs text-gray-400 block mb-1", children: "Ng\u01B0\u1EDDi l\u1EDBn" }), _jsx("input", { type: "number", value: adults, onChange: e => setAdults(Number(e.target.value)), className: "w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" })] }), _jsxs("div", { children: [_jsx("label", { className: "text-xs text-gray-400 block mb-1", children: "Tr\u1EBB em" }), _jsx("input", { type: "number", value: children, onChange: e => setChildren(Number(e.target.value)), className: "w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" })] }), _jsxs("div", { children: [_jsx("label", { className: "text-xs text-gray-400 block mb-1", children: "Gi\u1EA3m tr\u1EBB em (%)" }), _jsx("input", { type: "number", value: discount, onChange: e => setDiscount(Number(e.target.value)), className: "w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" })] })] })] }), _jsxs("div", { className: "bg-blue-600 rounded-2xl p-6 text-white shadow-lg shadow-blue-200", children: [_jsxs("div", { className: "flex justify-between items-start mb-8", children: [_jsxs("div", { children: [_jsx("p", { className: "text-blue-100 text-sm", children: "T\u1ED5ng chi ph\u00ED chuy\u1EBFn \u0111i" }), _jsxs("h2", { className: "text-3xl font-bold mt-1", children: [totals.total.toLocaleString(), " VND"] })] }), _jsx(Wallet, { className: "w-8 h-8 opacity-20" })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4 border-t border-blue-500 pt-6", children: [_jsxs("div", { children: [_jsx("p", { className: "text-blue-100 text-xs uppercase tracking-wider font-semibold", children: "M\u1ED7i ng\u01B0\u1EDDi l\u1EDBn" }), _jsxs("p", { className: "text-xl font-bold", children: [totals.adultPrice.toLocaleString(), "\u0111"] })] }), _jsxs("div", { children: [_jsxs("p", { className: "text-blue-100 text-xs uppercase tracking-wider font-semibold", children: ["M\u1ED7i tr\u1EBB em (-", discount, "%)"] }), _jsxs("p", { className: "text-xl font-bold", children: [totals.childPrice.toLocaleString(), "\u0111"] })] })] })] }), _jsxs("div", { className: "flex items-center gap-2 text-gray-400 text-xs px-2", children: [_jsx(Info, { className: "w-4 h-4" }), _jsx("p", { children: "Chi ph\u00ED \u0111\u01B0\u1EE3c t\u1EF1 \u0111\u1ED9ng t\u00EDnh to\u00E1n d\u1EF1a tr\u00EAn h\u00F3a \u0111\u01A1n c\u1EE7a c\u00E1c ch\u1EB7ng." })] })] }));
|
||||||
};
|
};
|
||||||
exports.ExpenseManager = ExpenseManager;
|
|
||||||
//# sourceMappingURL=ExpenseManager.js.map
|
//# sourceMappingURL=ExpenseManager.js.map
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"ExpenseManager.js","sourceRoot":"","sources":["../ExpenseManager.tsx"],"names":[],"mappings":"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"}
|
{"version":3,"file":"ExpenseManager.js","sourceRoot":"","sources":["../ExpenseManager.tsx"],"names":[],"mappings":";AAAA,OAAc,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,OAAO,CAAC;AACjD,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAEjD,MAAM,CAAC,MAAM,cAAc,GAAG,GAAG,EAAE;IACjC,MAAM,EAAE,IAAI,EAAE,GAAG,YAAY,EAAE,CAAC;IAChC,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAC;IACxC,MAAM,CAAC,QAAQ,EAAE,WAAW,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAC;IAC5C,MAAM,CAAC,QAAQ,EAAE,WAAW,CAAC,GAAG,QAAQ,CAAC,EAAE,CAAC,CAAC;IAE7C,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,EAAE;QAC1B,MAAM,WAAW,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,CAC3C,GAAG,GAAG,GAAG,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,IAAY,EAAE,GAAQ,EAAE,EAAE,CAAC,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CACvF,CAAC;QAEF,MAAM,eAAe,GAAG,CAAC,GAAG,CAAC,QAAQ,GAAG,GAAG,CAAC,CAAC;QAC7C,MAAM,aAAa,GAAG,MAAM,GAAG,CAAC,QAAQ,GAAG,eAAe,CAAC,CAAC;QAC5D,MAAM,UAAU,GAAG,WAAW,GAAG,aAAa,CAAC;QAC/C,MAAM,UAAU,GAAG,UAAU,GAAG,eAAe,CAAC;QAEhD,OAAO;YACL,KAAK,EAAE,WAAW;YAClB,UAAU,EAAE,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC;YAClC,UAAU,EAAE,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC;SACnC,CAAC;IACJ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC;IAEvC,OAAO,CACL,eAAK,SAAS,EAAC,qDAAqD,aAClE,eAAK,SAAS,EAAC,2DAA2D,aACxE,eAAK,SAAS,EAAC,4CAA4C,aACzD,KAAC,KAAK,IAAC,SAAS,EAAC,SAAS,GAAG,EAC7B,aAAI,SAAS,EAAC,WAAW,wDAAyB,IAC9C,EACN,eAAK,SAAS,EAAC,wBAAwB,aACrC,0BACE,gBAAO,SAAS,EAAC,kCAAkC,yCAAkB,EACrE,gBAAO,IAAI,EAAC,QAAQ,EAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,CAAC,EAAE,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,EAAC,+EAA+E,GAAG,IAC9K,EACN,0BACE,gBAAO,SAAS,EAAC,kCAAkC,4BAAe,EAClE,gBAAO,IAAI,EAAC,QAAQ,EAAC,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,CAAC,CAAC,EAAE,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,EAAC,+EAA+E,GAAG,IAClL,EACN,0BACE,gBAAO,SAAS,EAAC,kCAAkC,0CAAwB,EAC3E,gBAAO,IAAI,EAAC,QAAQ,EAAC,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,CAAC,CAAC,EAAE,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,EAAC,+EAA+E,GAAG,IAClL,IACF,IACF,EAEN,eAAK,SAAS,EAAC,kEAAkE,aAC/E,eAAK,SAAS,EAAC,uCAAuC,aACpD,0BACE,YAAG,SAAS,EAAC,uBAAuB,2DAA2B,EAC/D,cAAI,SAAS,EAAC,yBAAyB,aAAE,MAAM,CAAC,KAAK,CAAC,cAAc,EAAE,YAAU,IAC5E,EACN,KAAC,MAAM,IAAC,SAAS,EAAC,oBAAoB,GAAG,IACrC,EACN,eAAK,SAAS,EAAC,sDAAsD,aACnE,0BACE,YAAG,SAAS,EAAC,8DAA8D,kDAAkB,EAC7F,aAAG,SAAS,EAAC,mBAAmB,aAAE,MAAM,CAAC,UAAU,CAAC,cAAc,EAAE,cAAM,IACtE,EACN,0BACE,aAAG,SAAS,EAAC,8DAA8D,wCAAe,QAAQ,UAAO,EACzG,aAAG,SAAS,EAAC,mBAAmB,aAAE,MAAM,CAAC,UAAU,CAAC,cAAc,EAAE,cAAM,IACtE,IACF,IACF,EAEN,eAAK,SAAS,EAAC,oDAAoD,aACjE,KAAC,IAAI,IAAC,SAAS,EAAC,SAAS,GAAG,EAC5B,8KAAqE,IACjE,IACF,CACP,CAAC;AACJ,CAAC,CAAC"}
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
export declare const ExploreMap: ({ onBack, onLogout, user, onViewTour }: {
|
||||||
|
onBack: () => void;
|
||||||
|
onLogout?: () => void;
|
||||||
|
user?: any;
|
||||||
|
onViewTour: (id: string) => void;
|
||||||
|
}) => React.JSX.Element;
|
||||||
Vendored
+99
@@ -0,0 +1,99 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { MapContainer, TileLayer, Marker, useMap, useMapEvents } from 'react-leaflet';
|
||||||
|
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||||
|
const MarkerClusterGroup = _MarkerClusterGroup.default || _MarkerClusterGroup;
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { useTourStore } from './useTourStore.js';
|
||||||
|
import { X, Navigation, LogOut, Settings } from 'lucide-react';
|
||||||
|
import { UserManagementModal } from './UserManagementModal.js';
|
||||||
|
import { CreateTourModal } from './CreateTourModal.js';
|
||||||
|
const DefaultIcon = L.icon({
|
||||||
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||||
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||||
|
iconSize: [25, 41],
|
||||||
|
iconAnchor: [12, 41],
|
||||||
|
});
|
||||||
|
L.Marker.prototype.options.icon = DefaultIcon;
|
||||||
|
function RecenterMap({ position }) {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
map.setView(position, map.getZoom());
|
||||||
|
}, [position, map]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function MapTracker() {
|
||||||
|
const setMapCenter = useTourStore(state => state.setMapCenter);
|
||||||
|
useMapEvents({
|
||||||
|
moveend: (e) => {
|
||||||
|
const map = e.target;
|
||||||
|
const center = map.getCenter();
|
||||||
|
const zoom = map.getZoom();
|
||||||
|
const coords = [center.lat, center.lng];
|
||||||
|
setMapCenter(coords);
|
||||||
|
localStorage.setItem('map_view_state', JSON.stringify({ center: coords, zoom }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
export const ExploreMap = ({ onBack, onLogout, user, onViewTour }) => {
|
||||||
|
const { publicTours, fetchPublicTours, fetchTour, setMapCenter } = useTourStore();
|
||||||
|
const [initialViewState] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('map_view_state');
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(saved);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const [userPos, setUserPos] = useState(initialViewState?.center || [10.7769, 106.7009]);
|
||||||
|
const [mapZoom] = useState(initialViewState?.zoom || 13);
|
||||||
|
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPublicTours();
|
||||||
|
if (!initialViewState) {
|
||||||
|
navigator.geolocation.getCurrentPosition((pos) => {
|
||||||
|
const posArray = [pos.coords.latitude, pos.coords.longitude];
|
||||||
|
setUserPos(posArray);
|
||||||
|
setMapCenter(posArray);
|
||||||
|
}, () => console.log("Không thể lấy vị trí người dùng"));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setMapCenter(initialViewState.center);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
return (_jsxs("div", { className: "h-screen w-full relative", children: [_jsx("button", { onClick: onBack, className: "absolute top-6 left-6 z-[1000] bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all", children: _jsx(X, { className: "w-6 h-6 text-gray-800" }) }), onLogout && (_jsxs("button", { onClick: onLogout, className: "absolute top-6 right-6 z-[1000] bg-white px-4 py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center gap-2 font-bold text-gray-700", children: [_jsx(LogOut, { className: "w-5 h-5" }), _jsx("span", { className: "hidden sm:inline", children: "\u0110\u0103ng xu\u1EA5t" })] })), user?.isAdmin && (_jsxs("button", { onClick: () => setIsAdminModalOpen(true), className: "absolute top-6 right-40 z-[1000] bg-blue-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center gap-2 font-bold", children: [_jsx(Settings, { className: "w-5 h-5" }), _jsx("span", { className: "hidden sm:inline", children: "Qu\u1EA3n l\u00FD h\u1EC7 th\u1ED1ng" })] })), user && (_jsxs("button", { onClick: () => setIsCreateModalOpen(true), className: "absolute top-6 right-80 z-[1000] bg-green-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center gap-2 font-bold", children: [_jsx(Navigation, { className: "w-5 h-5" }), _jsx("span", { className: "hidden sm:inline", children: "T\u1EA1o Tour m\u1EDBi" })] })), _jsx("div", { className: "absolute top-6 left-20 z-[1000] bg-white/90 backdrop-blur-md px-6 py-3 rounded-2xl shadow-xl border border-white/20 hidden sm:block", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Navigation, { className: "w-4 h-4 text-blue-600" }), _jsx("span", { className: "font-bold text-gray-800", children: "\u0110ang kh\u00E1m ph\u00E1 khu v\u1EF1c c\u1EE7a b\u1EA1n" })] }) }), _jsxs(MapContainer, { center: userPos, zoom: mapZoom, className: "h-full w-full", preferCanvas: true, children: [_jsx(TileLayer, { url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", attribution: '\u00A9 OpenStreetMap contributors' }), _jsx(MapTracker, {}), _jsx(RecenterMap, { position: userPos }), _jsx(MarkerClusterGroup, { chunkedLoading: true, children: publicTours.map((tour) => {
|
||||||
|
const startLoc = tour.legs?.[0]?.locations?.[0];
|
||||||
|
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
|
||||||
|
const markerPos = startLoc
|
||||||
|
? [startLoc.latitude, startLoc.longitude]
|
||||||
|
: userPos;
|
||||||
|
return (_jsx(React.Fragment, { children: _jsx(Marker, { position: markerPos, eventHandlers: {
|
||||||
|
click: () => onViewTour(tour.id)
|
||||||
|
}, icon: L.divIcon({
|
||||||
|
className: 'custom-bubble',
|
||||||
|
html: `
|
||||||
|
<div class="relative group">
|
||||||
|
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
|
||||||
|
<img src="${tourImage}" class="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
|
||||||
|
S
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
iconSize: [48, 48],
|
||||||
|
iconAnchor: [24, 24]
|
||||||
|
}) }) }, tour.id));
|
||||||
|
}) })] }), _jsx(UserManagementModal, { isOpen: isAdminModalOpen, onClose: () => setIsAdminModalOpen(false) }), _jsx(CreateTourModal, { isOpen: isCreateModalOpen, onClose: () => setIsCreateModalOpen(false), onSuccess: (tour) => {
|
||||||
|
fetchTour(tour.id);
|
||||||
|
onViewTour(tour.id);
|
||||||
|
} })] }));
|
||||||
|
};
|
||||||
|
//# sourceMappingURL=ExploreMap.js.map
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+4
-1
@@ -1,2 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
export declare const ItineraryTimeline: () => React.JSX.Element;
|
export declare const ItineraryTimeline: ({ onAddLocation, onEditLocation }: {
|
||||||
|
onAddLocation?: (legId: string) => void;
|
||||||
|
onEditLocation?: (location: any) => void;
|
||||||
|
}) => React.JSX.Element;
|
||||||
|
|||||||
Vendored
+107
-15
@@ -1,23 +1,115 @@
|
|||||||
"use strict";
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
import { useState } from 'react';
|
||||||
exports.ItineraryTimeline = void 0;
|
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
||||||
const jsx_runtime_1 = require("react/jsx-runtime");
|
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List } from 'lucide-react';
|
||||||
const date_fns_1 = require("date-fns");
|
import { useTourStore } from './useTourStore.js';
|
||||||
const lucide_react_1 = require("lucide-react");
|
import { ConfirmModal } from './ConfirmModal.js';
|
||||||
const useTourStore_1 = require("./useTourStore");
|
|
||||||
const TimeVariance = ({ planned, actual }) => {
|
const TimeVariance = ({ planned, actual }) => {
|
||||||
if (!actual)
|
if (!actual)
|
||||||
return null;
|
return null;
|
||||||
const diff = (0, date_fns_1.differenceInMinutes)((0, date_fns_1.parseISO)(actual), (0, date_fns_1.parseISO)(planned));
|
const diff = differenceInMinutes(parseISO(actual), parseISO(planned));
|
||||||
const isLate = diff > 0;
|
const isLate = diff > 0;
|
||||||
return ((0, jsx_runtime_1.jsxs)("div", { className: `flex items-center text-xs font-medium mt-1 ${isLate ? 'text-red-500' : 'text-green-600'}`, children: [isLate ? (0, jsx_runtime_1.jsx)(lucide_react_1.AlertCircle, { className: "w-3 h-3 mr-1" }) : (0, jsx_runtime_1.jsx)(lucide_react_1.CheckCircle2, { className: "w-3 h-3 mr-1" }), (0, jsx_runtime_1.jsx)("span", { children: isLate ? `Trễ ${diff} phút` : diff === 0 ? 'Đúng giờ' : `Sớm ${Math.abs(diff)} phút` })] }));
|
return (_jsxs("div", { className: `flex items-center text-xs font-medium mt-1 ${isLate ? 'text-red-500' : 'text-green-600'}`, children: [isLate ? _jsx(AlertCircle, { className: "w-3 h-3 mr-1" }) : _jsx(CheckCircle2, { className: "w-3 h-3 mr-1" }), _jsx("span", { children: isLate ? `Trễ ${diff} phút` : diff === 0 ? 'Đúng giờ' : `Sớm ${Math.abs(diff)} phút` })] }));
|
||||||
};
|
};
|
||||||
const ItineraryTimeline = () => {
|
const calculateDistance = (lat1, lon1, lat2, lon2) => {
|
||||||
const { legs } = (0, useTourStore_1.useTourStore)();
|
const p = 0.017453292519943295;
|
||||||
const toggleComplete = async (placeId) => {
|
const c = Math.cos;
|
||||||
console.log("Toggle status for place:", placeId);
|
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));
|
||||||
};
|
};
|
||||||
return ((0, jsx_runtime_1.jsxs)("div", { className: "max-w-2xl mx-auto p-4 sm:p-6 bg-gray-50 min-h-screen", children: [(0, jsx_runtime_1.jsx)("h2", { className: "text-2xl font-bold text-gray-800 mb-8 px-2", children: "L\u1ED9 tr\u00ECnh chuy\u1EBFn \u0111i" }), (0, jsx_runtime_1.jsx)("div", { className: "space-y-8", children: legs.map((leg, legIdx) => ((0, jsx_runtime_1.jsxs)("div", { className: "relative", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center mb-4 px-2", children: [(0, jsx_runtime_1.jsxs)("div", { className: "bg-blue-600 text-white text-sm font-bold px-3 py-1 rounded-full shadow-sm", children: ["Ch\u1EB7ng ", leg.sequenceNumber] }), (0, jsx_runtime_1.jsx)("div", { className: "ml-4 h-[1px] flex-1 bg-gray-200" })] }), (0, jsx_runtime_1.jsx)("div", { className: "absolute left-6 top-10 bottom-0 w-0.5 bg-gray-200 -z-0" }), (0, jsx_runtime_1.jsx)("div", { className: "space-y-6 ml-2", children: leg.places.map((place) => ((0, jsx_runtime_1.jsxs)("div", { className: "relative flex group", children: [(0, jsx_runtime_1.jsx)("div", { className: "z-10 mt-1.5 mr-4", children: (0, jsx_runtime_1.jsx)("button", { onClick: () => toggleComplete(place.id), className: `transition-colors duration-200 ${place.isCompleted ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`, children: place.isCompleted ? ((0, jsx_runtime_1.jsx)(lucide_react_1.CheckCircle2, { className: "w-8 h-8 bg-white rounded-full" })) : ((0, jsx_runtime_1.jsx)(lucide_react_1.Circle, { className: "w-8 h-8 bg-white rounded-full fill-white" })) }) }), (0, jsx_runtime_1.jsxs)("div", { className: `flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${place.isCompleted ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'}`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex justify-between items-start", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("h3", { className: `font-semibold text-lg ${place.isCompleted ? 'text-gray-500 line-through' : 'text-gray-800'}`, children: place.name }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center text-sm text-gray-500 mt-1", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.MapPin, { className: "w-3 h-3 mr-1" }), (0, jsx_runtime_1.jsx)("span", { className: "truncate max-w-[200px] sm:max-w-md", children: place.address })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "text-right flex flex-col items-end", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center text-sm font-medium text-blue-600", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Clock, { className: "w-3 h-3 mr-1" }), (0, date_fns_1.format)((0, date_fns_1.parseISO)(place.arrivalTime), 'HH:mm')] }), place.isCompleted && place.completedAt && ((0, jsx_runtime_1.jsxs)("div", { className: "text-[10px] text-gray-400 mt-1 italic", children: ["Th\u1EF1c t\u1EBF: ", (0, date_fns_1.format)((0, date_fns_1.parseISO)(place.completedAt), 'HH:mm')] }))] })] }), (0, jsx_runtime_1.jsx)(TimeVariance, { planned: place.arrivalTime, actual: place.completedAt })] })] }, place.id))) }), leg.notes && ((0, jsx_runtime_1.jsxs)("p", { className: "ml-14 mt-4 text-sm text-gray-400 italic", children: ["* ", leg.notes] }))] }, leg.id))) })] }));
|
const formatTravelTime = (minutes) => {
|
||||||
|
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 }) => {
|
||||||
|
const { currentTour, legs, optimizeRouting, userRole, addLeg, updateLeg, deleteLeg, initializeLegs, deleteLocation } = useTourStore();
|
||||||
|
const [confirmState, setConfirmState] = useState({ open: false });
|
||||||
|
const toggleComplete = async (locationId) => {
|
||||||
|
console.log("Toggle status for location:", locationId);
|
||||||
|
};
|
||||||
|
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 () => {
|
||||||
|
const countStr = window.prompt("Chuyến đi này bạn muốn chia làm bao nhiêu chặng?", "3");
|
||||||
|
const count = parseInt(countStr || "0");
|
||||||
|
if (count > 0 && currentTour) {
|
||||||
|
await initializeLegs(currentTour.id, count);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleEditLeg = async (leg) => {
|
||||||
|
const note = window.prompt("Sửa ghi chú chặng:", leg.note || "");
|
||||||
|
if (note !== null) {
|
||||||
|
await updateLeg(leg.id, { note });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleDeleteLeg = async (legId) => {
|
||||||
|
setConfirmState({
|
||||||
|
open: true,
|
||||||
|
title: 'Xóa chặng',
|
||||||
|
message: 'Bạn có chắc chắn muốn xóa chặng này?',
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await deleteLeg(legId);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setConfirmState({ open: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleDeleteLocation = async (id) => {
|
||||||
|
setConfirmState({
|
||||||
|
open: true,
|
||||||
|
title: 'Xóa địa điểm',
|
||||||
|
message: 'Bạn có chắc chắn muốn xóa địa điểm này?',
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await deleteLocation(id);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setConfirmState({ open: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (_jsxs("div", { className: "max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen", children: [_jsxs("div", { className: "px-2 pt-4", children: [legs.length === 0 ? (_jsxs("div", { className: "text-center py-20 bg-white rounded-3xl border-2 border-dashed border-gray-200", children: [_jsx(List, { className: "w-12 h-12 text-gray-300 mx-auto mb-4" }), _jsx("p", { className: "text-gray-500 font-medium", children: "Ch\u01B0a c\u00F3 ch\u1EB7ng n\u00E0o trong l\u1ED9 tr\u00ECnh." })] })) : (legs.map((leg, legIdx) => {
|
||||||
|
const totalDwellMinutes = leg.locations.reduce((acc, loc) => {
|
||||||
|
if (loc.plannedStart && loc.plannedEnd) {
|
||||||
|
return acc + differenceInMinutes(parseISO(loc.plannedEnd), parseISO(loc.plannedStart));
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, 0);
|
||||||
|
const prevLegLastLoc = legIdx > 0 ? legs[legIdx - 1]?.locations.slice(-1)[0] : null;
|
||||||
|
return (_jsxs("div", { className: "relative mb-12 animate-in fade-in slide-in-from-bottom-4 duration-300", children: [_jsxs("div", { className: "sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2", children: [_jsxs("div", { className: "font-black text-blue-600 text-xl truncate flex-1 flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm", children: leg.sequence }), leg.note || `Chi tiết Chặng ${leg.sequence}`] }), prevLegLastLoc && (_jsxs("div", { className: "flex items-center gap-1 text-[10px] text-gray-400 uppercase tracking-widest ml-10", children: [_jsx(Navigation, { className: "w-2.5 h-2.5 rotate-90" }), " Ti\u1EBFp n\u1ED1i t\u1EEB ", prevLegLastLoc.name] }))] }), _jsxs("div", { className: "flex items-center gap-2 ml-4", children: [['OWNER', 'MANAGER'].includes(userRole || '') && (_jsxs(_Fragment, { children: [_jsx("button", { onClick: () => onAddLocation?.(leg.id), className: "p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all", title: "Th\u00EAm \u0111\u1ECBa \u0111i\u1EC3m v\u00E0o ch\u1EB7ng n\u00E0y", children: _jsx(Plus, { className: "w-4 h-4" }) }), _jsx("button", { onClick: () => handleEditLeg(leg), className: "p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all", children: _jsx(Edit2, { className: "w-4 h-4" }) }), _jsx("button", { onClick: () => handleDeleteLeg(leg.id), className: "p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all", children: _jsx(Trash2, { className: "w-4 h-4" }) })] })), leg.totalDistance !== undefined && (_jsxs("div", { className: "hidden sm:flex items-center gap-2", children: [_jsxs("div", { className: "text-xs font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded-lg border border-blue-100", children: [leg.totalDistance, " km"] }), _jsxs("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", children: [_jsx(Clock, { className: "w-3 h-3" }), "~ ", formatTravelTime(Math.round((leg.totalDistance / 35) * 60))] })] })), totalDwellMinutes > 0 && (_jsxs("div", { className: "hidden md:flex text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 items-center gap-1", children: [_jsx(Clock, { className: "w-3 h-3" }), "D\u1EEBng: ", formatTravelTime(totalDwellMinutes)] }))] }), ['OWNER', 'MANAGER'].includes(userRole || '') && legs.length > 0 && (_jsxs("button", { onClick: () => optimizeRouting(leg.id), className: "ml-auto flex items-center gap-1.5 text-xs font-bold text-indigo-600 hover:text-indigo-700 bg-indigo-50 hover:bg-indigo-100 px-3 py-1.5 rounded-xl transition-all", children: [_jsx(Zap, { className: "w-3 h-3" }), "T\u1ED1i \u01B0u"] }))] }), _jsx("div", { className: `absolute left-6 top-16 ${legIdx === legs.length - 1 ? 'bottom-0' : 'bottom-[-3.5rem]'} w-0.5 bg-blue-100 -z-0` }), _jsx("div", { className: "ml-2", children: leg.locations.map((location, idx) => {
|
||||||
|
const nextLocation = leg.locations[idx + 1] || legs[legIdx + 1]?.locations[0];
|
||||||
|
const distanceToNext = nextLocation
|
||||||
|
? calculateDistance(location.latitude, location.longitude, nextLocation.latitude, nextLocation.longitude)
|
||||||
|
: null;
|
||||||
|
const averageSpeed = 35;
|
||||||
|
const travelTimeMinutes = distanceToNext ? Math.round((distanceToNext / averageSpeed) * 60) : null;
|
||||||
|
const dwellMinutes = (location.plannedStart && location.plannedEnd)
|
||||||
|
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
||||||
|
: null;
|
||||||
|
const locationExpense = leg.expenses?.find((e) => e.locationId === location.id);
|
||||||
|
const isStartPoint = legs[0]?.id === leg.id && idx === 0;
|
||||||
|
const isEndPoint = legs[legs.length - 1]?.id === leg.id && idx === leg.locations.length - 1;
|
||||||
|
return (_jsxs("div", { children: [_jsxs("div", { className: "relative flex group mb-6", children: [_jsx("div", { className: "z-10 mt-1.5 mr-4", children: _jsx("button", { onClick: () => toggleComplete(location.id), className: `transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`, children: location.status === 'COMPLETED' ? (_jsx(CheckCircle2, { className: "w-8 h-8 bg-white rounded-full" })) : (_jsx(Circle, { className: "w-8 h-8 bg-white rounded-full fill-white" })) }) }), _jsxs("div", { className: `flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${location.status === 'COMPLETED' ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'}`, children: [_jsxs("div", { className: "flex justify-between items-start", children: [_jsxs("div", { children: [isStartPoint && (_jsx("span", { className: "inline-block px-2 py-0.5 bg-green-100 text-green-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider", children: "\u0110i\u1EC3m b\u1EAFt \u0111\u1EA7u" })), isEndPoint && (_jsx("span", { className: "inline-block px-2 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider", children: "\u0110i\u1EC3m k\u1EBFt th\u00FAc" })), _jsx("h3", { className: `font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`, children: location.name }), _jsxs("div", { className: "flex items-center text-sm text-gray-500 mt-1", children: [_jsx(MapPin, { className: "w-3 h-3 mr-1" }), _jsx("span", { className: "truncate max-w-[200px] sm:max-w-md", children: location.address })] }), location.note && (_jsx("div", { className: "mt-2 text-xs text-gray-600 bg-gray-50 p-2 rounded-lg border border-gray-100 italic", children: location.note })), dwellMinutes !== null && (_jsxs("div", { className: "flex items-center text-xs text-amber-600 font-medium mt-1", children: [_jsx(Clock, { className: "w-3 h-3 mr-1" }), _jsxs("span", { children: ["Th\u1EDDi gian d\u1EEBng: ", formatTravelTime(dwellMinutes)] })] })), locationExpense && (_jsxs("div", { className: "mt-2 text-xs text-indigo-600 bg-indigo-50/80 p-2 rounded-lg border border-indigo-100 space-y-1", children: [_jsxs("div", { className: "flex items-center gap-1 font-bold", children: [_jsx(Zap, { className: "w-3 h-3" }), _jsxs("span", { children: ["Chi ph\u00ED: ", Number(locationExpense.amount).toLocaleString(), "\u0111 (", locationExpense.category, ")"] })] }), locationExpense.description && (_jsxs("div", { className: "text-[10px] text-gray-600", children: ["D\u1ECBch v\u1EE5: ", locationExpense.description] })), locationExpense.note && (_jsx("div", { className: "text-[10px] text-gray-500 italic", children: locationExpense.note })), locationExpense.paidBy && (_jsxs("div", { className: "text-[10px] font-semibold text-indigo-700", children: ["\u0110\u00E3 thanh to\u00E1n: ", locationExpense.paidBy.name] }))] }))] }), _jsxs("div", { className: "text-right flex flex-col items-end", children: [_jsxs("div", { className: "flex items-center text-sm font-medium text-blue-600", children: [_jsx(Clock, { className: "w-3 h-3 mr-1" }), location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'] }), location.status === 'COMPLETED' && location.actualStart && (_jsxs("div", { className: "text-[10px] text-gray-400 mt-1 italic", children: ["Th\u1EF1c t\u1EBF: ", format(parseISO(location.actualStart), 'HH:mm')] })), ['OWNER', 'MANAGER'].includes(userRole || '') && !isStartPoint && !isEndPoint && (_jsxs("div", { className: "flex gap-1 mt-2", children: [_jsx("button", { onClick: () => onEditLocation?.(location), className: "p-1 text-gray-400 hover:text-blue-600 transition-colors", children: _jsx(Edit2, { className: "w-3.5 h-3.5" }) }), _jsx("button", { onClick: () => handleDeleteLocation(location.id), className: "p-1 text-gray-400 hover:text-red-600 transition-colors", children: _jsx(Trash2, { className: "w-3.5 h-3.5" }) })] }))] })] }), _jsx(TimeVariance, { planned: location.plannedStart || '', actual: location.actualStart || null })] })] }), distanceToNext !== null && travelTimeMinutes !== null && (_jsxs("div", { className: "ml-4 -mt-4 mb-2 flex items-center gap-3", children: [_jsx("div", { className: "w-8 flex justify-center", children: _jsx(Navigation, { className: "w-3 h-3 text-blue-400 rotate-180" }) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("span", { className: "text-[10px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100", children: [distanceToNext.toFixed(2), " km"] }), _jsxs("span", { className: "text-[10px] font-bold text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full border border-gray-100 flex items-center gap-1", children: [_jsx(Clock, { className: "w-2.5 h-2.5" }), "~ ", formatTravelTime(travelTimeMinutes)] })] })] }))] }, location.id));
|
||||||
|
}) })] }, leg.id));
|
||||||
|
})), ['OWNER', 'MANAGER'].includes(userRole || '') && (_jsxs("div", { className: "flex flex-col gap-3 pb-20 mt-8", children: [_jsxs("button", { onClick: handleDeclareLegs, className: "w-full py-4 rounded-2xl bg-white text-blue-600 border-2 border-dashed border-blue-200 hover:bg-blue-50 transition-all flex items-center justify-center gap-2 text-sm font-bold", children: [_jsx(List, { className: "w-5 h-5" }), legs.length > 0 ? "Khai báo lại số lượng chặng" : "Bắt đầu bằng việc khai báo số chặng"] }), _jsxs("button", { onClick: handleAddLeg, className: "w-full py-4 rounded-2xl bg-blue-600 text-white shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all flex items-center justify-center gap-2 text-sm font-bold", children: [_jsx(Plus, { className: "w-5 h-5" }), " Th\u00EAm ch\u1EB7ng l\u1EBB v\u00E0o cu\u1ED1i"] })] }))] }), _jsx(ConfirmModal, { isOpen: confirmState.open, title: confirmState.title, message: confirmState.message, onConfirm: () => confirmState.onConfirm?.(), onCancel: () => setConfirmState({ open: false }) })] }));
|
||||||
};
|
};
|
||||||
exports.ItineraryTimeline = ItineraryTimeline;
|
|
||||||
//# sourceMappingURL=ItineraryTimeline.js.map
|
//# sourceMappingURL=ItineraryTimeline.js.map
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+4
@@ -1,6 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
interface LandingPageProps {
|
interface LandingPageProps {
|
||||||
onContinue?: () => void;
|
onContinue?: () => void;
|
||||||
|
onGoToSignup?: () => void;
|
||||||
|
onGoToMap?: () => void;
|
||||||
|
onLoginSuccess?: (user: any) => void;
|
||||||
|
isInitialSetup?: boolean;
|
||||||
}
|
}
|
||||||
export declare const LandingPage: React.FC<LandingPageProps>;
|
export declare const LandingPage: React.FC<LandingPageProps>;
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
Vendored
+18
-11
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"LandingPage.js","sourceRoot":"","sources":["../LandingPage.tsx"],"names":[],"mappings":";;;;AAAA,iCAAwC;AACxC,+CAAkE;AAClE,6CAA0C;AAMnC,MAAM,WAAW,GAA+B,CAAC,EAAE,UAAU,EAAE,EAAE,EAAE;IACxE,MAAM,CAAC,gBAAgB,EAAE,mBAAmB,CAAC,GAAG,IAAA,gBAAQ,EAAC,KAAK,CAAC,CAAC;IAEhE,OAAO,CACL,iCAAK,SAAS,EAAC,8EAA8E,aAE3F,iCAAK,SAAS,EAAC,+CAA+C,aAC5D,gCACE,GAAG,EAAC,+FAA+F,EACnG,GAAG,EAAC,2BAA2B,EAC/B,SAAS,EAAC,wDAAwD,GAClE,EAEF,gCAAK,SAAS,EAAC,mEAAmE,GAAG,EAErF,iCAAK,SAAS,EAAC,4CAA4C,aACzD,iCAAK,SAAS,EAAC,8BAA8B,aAC3C,gCAAK,SAAS,EAAC,8CAA8C,YAC3D,uBAAC,sBAAO,IAAC,SAAS,EAAC,oBAAoB,GAAG,GACtC,EACN,iCAAM,SAAS,EAAC,gDAAgD,+BAAsB,IAClF,EACN,+BAAI,SAAS,EAAC,2CAA2C,4FAEpD,EACL,8BAAG,SAAS,EAAC,gDAAgD,uKAEzD,IACA,IACF,EAGN,gCAAK,SAAS,EAAC,+EAA+E,YAC5F,iCAAK,SAAS,EAAC,4BAA4B,aACzC,iCAAK,SAAS,EAAC,WAAW,aACxB,+BAAI,SAAS,EAAC,iEAAiE,mHAE1E,EACL,8BAAG,SAAS,EAAC,uCAAuC,+PAEhD,IACA,EAEN,iCAAK,SAAS,EAAC,qBAAqB,aAElC,oCACE,OAAO,EAAE,GAAG,EAAE,CAAC,mBAAmB,CAAC,IAAI,CAAC,EACxC,SAAS,EAAC,gLAAgL,aAE1L,uBAAC,oBAAK,IAAC,SAAS,EAAC,wDAAwD,GAAG,qCAErE,EAGT,oCACE,OAAO,EAAE,UAAU,EACnB,SAAS,EAAC,yKAAyK,sDAGnL,uBAAC,yBAAU,IAAC,SAAS,EAAC,SAAS,GAAG,IAC3B,IACL,EAEN,iCAAK,SAAS,EAAC,8EAA8E,aAC3F,gCAAK,SAAS,EAAC,iBAAiB,YAC7B,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CACrB,gCAAa,SAAS,EAAC,8CAA8C,EAAC,GAAG,EAAE,iCAAiC,CAAC,GAAC,EAAE,EAAE,EAAE,GAAG,EAAC,MAAM,IAApH,CAAC,CAAsH,CAClI,CAAC,GACE,EACN,8BAAG,SAAS,EAAC,aAAa,iFAA4C,IAClE,IACF,GACF,EAGN,uBAAC,uBAAU,IAAC,MAAM,EAAE,gBAAgB,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,mBAAmB,CAAC,KAAK,CAAC,GAAI,IAC/E,CACP,CAAC;AACJ,CAAC,CAAC;AA9EW,QAAA,WAAW,eA8EtB"}
|
{"version":3,"file":"LandingPage.js","sourceRoot":"","sources":["../LandingPage.tsx"],"names":[],"mappings":"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"}
|
||||||
Vendored
+2
@@ -2,6 +2,8 @@ import React from 'react';
|
|||||||
interface LoginModalProps {
|
interface LoginModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onSwitchToSignup?: () => void;
|
||||||
|
onLoginSuccess?: (user: any) => void;
|
||||||
}
|
}
|
||||||
export declare const LoginModal: React.FC<LoginModalProps>;
|
export declare const LoginModal: React.FC<LoginModalProps>;
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
Vendored
+38
-8
@@ -1,12 +1,42 @@
|
|||||||
"use strict";
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
import { useState } from 'react';
|
||||||
exports.LoginModal = void 0;
|
import { X, Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
|
||||||
const jsx_runtime_1 = require("react/jsx-runtime");
|
export const LoginModal = ({ isOpen, onClose, onSwitchToSignup, onLoginSuccess }) => {
|
||||||
const lucide_react_1 = require("lucide-react");
|
const [email, setEmail] = useState('');
|
||||||
const LoginModal = ({ isOpen, onClose }) => {
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
if (!isOpen)
|
if (!isOpen)
|
||||||
return null;
|
return null;
|
||||||
return ((0, jsx_runtime_1.jsxs)("div", { className: "fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6", children: [(0, jsx_runtime_1.jsx)("div", { className: "absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-300", onClick: onClose }), (0, jsx_runtime_1.jsx)("div", { className: "relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300", children: (0, jsx_runtime_1.jsxs)("div", { className: "p-8 sm:p-10", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex justify-between items-start mb-8", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("h2", { className: "text-3xl font-bold text-gray-900", children: "\u0110\u0103ng nh\u1EADp" }), (0, jsx_runtime_1.jsx)("p", { className: "text-gray-500 mt-2", children: "Ch\u00E0o m\u1EEBng b\u1EA1n quay tr\u1EDF l\u1EA1i!" })] }), (0, jsx_runtime_1.jsx)("button", { onClick: onClose, className: "p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400 hover:text-gray-600", children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "w-6 h-6" }) })] }), (0, jsx_runtime_1.jsxs)("form", { className: "space-y-6", onSubmit: (e) => e.preventDefault(), children: [(0, jsx_runtime_1.jsxs)("div", { className: "space-y-2", children: [(0, jsx_runtime_1.jsx)("label", { className: "text-sm font-semibold text-gray-700 ml-1", children: "Email" }), (0, jsx_runtime_1.jsxs)("div", { className: "relative group", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Mail, { className: "absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" }), (0, jsx_runtime_1.jsx)("input", { type: "email", placeholder: "name@example.com", className: "w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all" })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "space-y-2", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex justify-between items-center px-1", children: [(0, jsx_runtime_1.jsx)("label", { className: "text-sm font-semibold text-gray-700", children: "M\u1EADt kh\u1EA9u" }), (0, jsx_runtime_1.jsx)("button", { className: "text-xs font-bold text-blue-600 hover:text-blue-700", children: "Qu\u00EAn m\u1EADt kh\u1EA9u?" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "relative group", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Lock, { className: "absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" }), (0, jsx_runtime_1.jsx)("input", { type: "password", placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022", className: "w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all" })] })] }), (0, jsx_runtime_1.jsxs)("button", { type: "submit", className: "w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-4 rounded-2xl shadow-lg shadow-blue-200 transition-all active:scale-[0.98]", children: ["\u0110\u0103ng nh\u1EADp", (0, jsx_runtime_1.jsx)(lucide_react_1.ArrowRight, { className: "w-5 h-5" })] })] }), (0, jsx_runtime_1.jsx)("div", { className: "mt-10 pt-8 border-t border-gray-100 text-center", children: (0, jsx_runtime_1.jsxs)("p", { className: "text-gray-500", children: ["Ch\u01B0a c\u00F3 t\u00E0i kho\u1EA3n?", ' ', (0, jsx_runtime_1.jsx)("button", { className: "font-bold text-blue-600 hover:underline", children: "T\u1EA1o t\u00E0i kho\u1EA3n ngay" })] }) })] }) })] }));
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || 'Đăng nhập thất bại');
|
||||||
|
}
|
||||||
|
localStorage.setItem('token', data.access_token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
if (onLoginSuccess) {
|
||||||
|
onLoginSuccess(data.user);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (_jsxs("div", { className: "fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-300", onClick: onClose }), _jsx("div", { className: "relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300", children: _jsxs("div", { className: "p-8 sm:p-10", children: [_jsxs("div", { className: "flex justify-between items-start mb-8", children: [_jsxs("div", { children: [_jsx("h2", { className: "text-3xl font-bold text-gray-900", children: "\u0110\u0103ng nh\u1EADp" }), _jsx("p", { className: "text-gray-500 mt-2", children: "Ch\u00E0o m\u1EEBng b\u1EA1n quay tr\u1EDF l\u1EA1i!" })] }), _jsx("button", { onClick: onClose, className: "p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400 hover:text-gray-600", children: _jsx(X, { className: "w-6 h-6" }) })] }), error && (_jsx("div", { className: "mb-6 p-4 bg-red-50 text-red-600 rounded-2xl text-sm font-bold animate-in fade-in slide-in-from-top-1", children: error })), _jsxs("form", { className: "space-y-6", onSubmit: handleSubmit, children: [_jsxs("div", { className: "space-y-2", children: [_jsx("label", { className: "text-sm font-semibold text-gray-700 ml-1", children: "Email" }), _jsxs("div", { className: "relative group", children: [_jsx(Mail, { className: "absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" }), _jsx("input", { type: "email", placeholder: "name@example.com", value: email, onChange: (e) => setEmail(e.target.value), required: true, className: "w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all" })] })] }), _jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex justify-between items-center px-1", children: [_jsx("label", { className: "text-sm font-semibold text-gray-700", children: "M\u1EADt kh\u1EA9u" }), _jsx("button", { className: "text-xs font-bold text-blue-600 hover:text-blue-700", children: "Qu\u00EAn m\u1EADt kh\u1EA9u?" })] }), _jsxs("div", { className: "relative group", children: [_jsx(Lock, { className: "absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" }), _jsx("input", { type: "password", placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022", value: password, onChange: (e) => setPassword(e.target.value), required: true, className: "w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all" })] })] }), _jsxs("button", { type: "submit", disabled: isLoading, className: "w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-4 rounded-2xl shadow-lg shadow-blue-200 transition-all active:scale-[0.98] disabled:opacity-50", children: [isLoading ? 'Đang xử lý...' : 'Đăng nhập', isLoading ? _jsx(Loader2, { className: "w-5 h-5 animate-spin" }) : _jsx(ArrowRight, { className: "w-5 h-5" })] })] }), _jsx("div", { className: "mt-10 pt-8 border-t border-gray-100 text-center", children: _jsxs("p", { className: "text-gray-500", children: ["Ch\u01B0a c\u00F3 t\u00E0i kho\u1EA3n?", ' ', _jsx("button", { onClick: () => { onClose(); onSwitchToSignup?.(); }, className: "font-bold text-blue-600 hover:underline", children: "T\u1EA1o t\u00E0i kho\u1EA3n ngay" })] }) })] }) })] }));
|
||||||
};
|
};
|
||||||
exports.LoginModal = LoginModal;
|
|
||||||
//# sourceMappingURL=LoginModal.js.map
|
//# sourceMappingURL=LoginModal.js.map
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"LoginModal.js","sourceRoot":"","sources":["../LoginModal.tsx"],"names":[],"mappings":";;;;AACA,+CAAyD;AAOlD,MAAM,UAAU,GAA8B,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;IAC3E,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAC;IAEzB,OAAO,CACL,iCAAK,SAAS,EAAC,gEAAgE,aAE7E,gCACE,SAAS,EAAC,kFAAkF,EAC5F,OAAO,EAAE,OAAO,GAChB,EAGF,gCAAK,SAAS,EAAC,6GAA6G,YAC1H,iCAAK,SAAS,EAAC,aAAa,aAC1B,iCAAK,SAAS,EAAC,uCAAuC,aACpD,4CACE,+BAAI,SAAS,EAAC,kCAAkC,yCAAe,EAC/D,8BAAG,SAAS,EAAC,oBAAoB,qEAAgC,IAC7D,EACN,mCACE,OAAO,EAAE,OAAO,EAChB,SAAS,EAAC,wFAAwF,YAElG,uBAAC,gBAAC,IAAC,SAAS,EAAC,SAAS,GAAG,GAClB,IACL,EAEN,kCAAM,SAAS,EAAC,WAAW,EAAC,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,cAAc,EAAE,aAC7D,iCAAK,SAAS,EAAC,WAAW,aACxB,kCAAO,SAAS,EAAC,0CAA0C,sBAAc,EACzE,iCAAK,SAAS,EAAC,gBAAgB,aAC7B,uBAAC,mBAAI,IAAC,SAAS,EAAC,mHAAmH,GAAG,EACtI,kCACE,IAAI,EAAC,OAAO,EACZ,WAAW,EAAC,kBAAkB,EAC9B,SAAS,EAAC,kJAAkJ,GAC5J,IACE,IACF,EAEN,iCAAK,SAAS,EAAC,WAAW,aACxB,iCAAK,SAAS,EAAC,wCAAwC,aACrD,kCAAO,SAAS,EAAC,qCAAqC,mCAAiB,EACvE,mCAAQ,SAAS,EAAC,qDAAqD,8CAAwB,IAC3F,EACN,iCAAK,SAAS,EAAC,gBAAgB,aAC7B,uBAAC,mBAAI,IAAC,SAAS,EAAC,mHAAmH,GAAG,EACtI,kCACE,IAAI,EAAC,UAAU,EACf,WAAW,EAAC,kDAAU,EACtB,SAAS,EAAC,kJAAkJ,GAC5J,IACE,IACF,EAEN,oCACE,IAAI,EAAC,QAAQ,EACb,SAAS,EAAC,gLAAgL,yCAG1L,uBAAC,yBAAU,IAAC,SAAS,EAAC,SAAS,GAAG,IAC3B,IACJ,EAEP,gCAAK,SAAS,EAAC,iDAAiD,YAC9D,+BAAG,SAAS,EAAC,eAAe,uDACP,GAAG,EACtB,mCAAQ,SAAS,EAAC,yCAAyC,kDAA4B,IACrF,GACA,IACF,GACF,IACF,CACP,CAAC;AACJ,CAAC,CAAC;AA1EW,QAAA,UAAU,cA0ErB"}
|
{"version":3,"file":"LoginModal.js","sourceRoot":"","sources":["../LoginModal.tsx"],"names":[],"mappings":"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"}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react';
|
||||||
|
interface SignupPageProps {
|
||||||
|
onBack: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
export declare const SignupPage: React.FC<SignupPageProps>;
|
||||||
|
export {};
|
||||||
Vendored
+51
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"SignupPage.js","sourceRoot":"","sources":["../SignupPage.tsx"],"names":[],"mappings":";AAAA,OAAc,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AACxC,OAAO,EAAE,IAAI,EAAE,IAAI,EAAQ,UAAU,EAAE,WAAW,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAOjG,MAAM,CAAC,MAAM,UAAU,GAA8B,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,EAAE,EAAE;IAC7E,MAAM,CAAC,QAAQ,EAAE,WAAW,CAAC,GAAG,QAAQ,CAAC;QACvC,IAAI,EAAE,EAAE;QACR,KAAK,EAAE,EAAE;QACT,QAAQ,EAAE,EAAE;QACZ,eAAe,EAAE,EAAE;QACnB,KAAK,EAAE,EAAE;QACT,OAAO,EAAE,EAAE;KACZ,CAAC,CAAC;IACH,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAC,EAAE,CAAC,CAAC;IACvC,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;IAElD,MAAM,YAAY,GAAG,KAAK,EAAE,CAAkB,EAAE,EAAE;QAChD,CAAC,CAAC,cAAc,EAAE,CAAC;QACnB,QAAQ,CAAC,EAAE,CAAC,CAAC;QAEb,IAAI,QAAQ,CAAC,QAAQ,KAAK,QAAQ,CAAC,eAAe,EAAE,CAAC;YACnD,QAAQ,CAAC,8BAA8B,CAAC,CAAC;YACzC,OAAO;QACT,CAAC;QAED,YAAY,CAAC,IAAI,CAAC,CAAC;QACnB,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,UAAU,MAAM,CAAC,QAAQ,CAAC,QAAQ,OAAO,CAAC;YAE3D,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,QAAQ,qBAAqB,EAAE;gBAC7D,MAAM,EAAE,MAAM;gBACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;gBAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;oBACnB,KAAK,EAAE,QAAQ,CAAC,KAAK;oBACrB,QAAQ,EAAE,QAAQ,CAAC,QAAQ;oBAC3B,IAAI,EAAE,QAAQ,CAAC,IAAI;oBACnB,KAAK,EAAE,QAAQ,CAAC,KAAK,IAAI,SAAS;oBAClC,OAAO,EAAE,QAAQ,CAAC,OAAO,IAAI,SAAS;iBACvC,CAAC;aACH,CAAC,CAAC;YAEH,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;YAEnC,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;gBACjB,MAAM,IAAI,KAAK,CAAC,IAAI,CAAC,OAAO,IAAI,kBAAkB,CAAC,CAAC;YACtD,CAAC;YAED,SAAS,EAAE,CAAC;QACd,CAAC;QAAC,OAAO,GAAQ,EAAE,CAAC;YAClB,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QACxB,CAAC;gBAAS,CAAC;YACT,YAAY,CAAC,KAAK,CAAC,CAAC;QACtB,CAAC;IACH,CAAC,CAAC;IAEF,OAAO,CACL,eAAK,SAAS,EAAC,yDAAyD,aAEtE,eAAK,SAAS,EAAC,+CAA+C,aAC5D,cACE,GAAG,EAAC,+FAA+F,EACnG,GAAG,EAAC,mBAAmB,EACvB,SAAS,EAAC,wDAAwD,GAClE,EACF,cAAK,SAAS,EAAC,oEAAoE,GAAG,EACtF,eAAK,SAAS,EAAC,iEAAiE,aAC9E,KAAC,OAAO,IAAC,SAAS,EAAC,SAAS,GAAG,EAC/B,eAAM,SAAS,EAAC,gDAAgD,+BAAsB,IAClF,EACN,eAAK,SAAS,EAAC,qDAAqD,aAClE,aAAI,SAAS,EAAC,yBAAyB,0FAAuC,EAC9E,YAAG,SAAS,EAAC,0BAA0B,iLAAgF,IACnH,IACF,EAGN,cAAK,SAAS,EAAC,kFAAkF,YAC/F,eAAK,SAAS,EAAC,yBAAyB,aACtC,kBAAQ,OAAO,EAAE,MAAM,EAAE,SAAS,EAAC,wFAAwF,aACzH,KAAC,WAAW,IAAC,SAAS,EAAC,SAAS,GAAG,sBAC5B,EAET,eAAK,SAAS,EAAC,OAAO,aACpB,aAAI,SAAS,EAAC,uCAAuC,sDAAuB,EAC5E,YAAG,SAAS,EAAC,gCAAgC,qHAAuD,IAChG,EAEL,KAAK,IAAI,CACR,cAAK,SAAS,EAAC,sGAAsG,YAClH,KAAK,GACF,CACP,EAED,gBAAM,SAAS,EAAC,WAAW,EAAC,QAAQ,EAAE,YAAY,aAChD,eAAK,SAAS,EAAC,aAAa,aAC1B,gBAAO,SAAS,EAAC,sCAAsC,yCAAkB,EACzE,eAAK,SAAS,EAAC,gBAAgB,aAC7B,KAAC,IAAI,IAAC,SAAS,EAAC,mHAAmH,GAAG,EACtI,gBACE,QAAQ,QACR,IAAI,EAAC,MAAM,EACX,WAAW,EAAC,wBAAc,EAC1B,KAAK,EAAE,QAAQ,CAAC,IAAI,EACpB,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,WAAW,CAAC,EAAE,GAAG,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,EACnE,SAAS,EAAC,kJAAkJ,GAC5J,IACE,IACF,EAEN,eAAK,SAAS,EAAC,aAAa,aAC1B,gBAAO,SAAS,EAAC,sCAAsC,sBAAc,EACrE,eAAK,SAAS,EAAC,gBAAgB,aAC7B,KAAC,IAAI,IAAC,SAAS,EAAC,mHAAmH,GAAG,EACtI,gBACE,QAAQ,QACR,IAAI,EAAC,OAAO,EACZ,WAAW,EAAC,mBAAmB,EAC/B,KAAK,EAAE,QAAQ,CAAC,KAAK,EACrB,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,WAAW,CAAC,EAAE,GAAG,QAAQ,EAAE,KAAK,EAAE,CAAC,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,EACpE,SAAS,EAAC,kJAAkJ,GAC5J,IACE,IACF,EAEN,eAAK,SAAS,EAAC,uCAAuC,aACpD,eAAK,SAAS,EAAC,aAAa,aAC1B,gBAAO,SAAS,EAAC,sCAAsC,kDAAsB,EAC7E,eAAK,SAAS,EAAC,gBAAgB,aAC7B,KAAC,KAAK,IAAC,SAAS,EAAC,mHAAmH,GAAG,EACvI,gBACE,IAAI,EAAC,KAAK,EACV,WAAW,EAAC,cAAc,EAC1B,KAAK,EAAE,QAAQ,CAAC,KAAK,EACrB,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,WAAW,CAAC,EAAE,GAAG,QAAQ,EAAE,KAAK,EAAE,CAAC,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,EACpE,SAAS,EAAC,kJAAkJ,GAC5J,IACE,IACF,EACN,eAAK,SAAS,EAAC,aAAa,aAC1B,gBAAO,SAAS,EAAC,sCAAsC,uCAAgB,EACvE,eAAK,SAAS,EAAC,gBAAgB,aAC7B,KAAC,MAAM,IAAC,SAAS,EAAC,mHAAmH,GAAG,EACxI,gBACE,IAAI,EAAC,MAAM,EACX,WAAW,EAAC,qBAAgB,EAC5B,KAAK,EAAE,QAAQ,CAAC,OAAO,EACvB,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,WAAW,CAAC,EAAE,GAAG,QAAQ,EAAE,OAAO,EAAE,CAAC,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,EACtE,SAAS,EAAC,kJAAkJ,GAC5J,IACE,IACF,IACF,EAEN,eAAK,SAAS,EAAC,uCAAuC,aACpD,eAAK,SAAS,EAAC,aAAa,aAC1B,gBAAO,SAAS,EAAC,sCAAsC,mCAAiB,EACxE,gBACE,QAAQ,QACR,IAAI,EAAC,UAAU,EACf,WAAW,EAAC,kDAAU,EACtB,KAAK,EAAE,QAAQ,CAAC,QAAQ,EACxB,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,WAAW,CAAC,EAAE,GAAG,QAAQ,EAAE,QAAQ,EAAE,CAAC,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,EACvE,SAAS,EAAC,4IAA4I,GACtJ,IACE,EACN,eAAK,SAAS,EAAC,aAAa,aAC1B,gBAAO,SAAS,EAAC,sCAAsC,mCAAiB,EACxE,gBACE,QAAQ,QACR,IAAI,EAAC,UAAU,EACf,WAAW,EAAC,kDAAU,EACtB,KAAK,EAAE,QAAQ,CAAC,eAAe,EAC/B,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,WAAW,CAAC,EAAE,GAAG,QAAQ,EAAE,eAAe,EAAE,CAAC,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,EAC9E,SAAS,EAAC,4IAA4I,GACtJ,IACE,IACF,EAEN,kBACE,QAAQ,EAAE,SAAS,EACnB,IAAI,EAAC,QAAQ,EACb,SAAS,EAAC,yMAAyM,aAElN,SAAS,CAAC,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,eAAe,EAC7C,CAAC,SAAS,IAAI,KAAC,UAAU,IAAC,SAAS,EAAC,SAAS,GAAG,IAC1C,IACJ,IACH,GACF,IACF,CACP,CAAC;AACJ,CAAC,CAAC"}
|
||||||
Vendored
+3
-1
@@ -1,2 +1,4 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
export declare const TourDetailPage: () => React.JSX.Element;
|
export declare const TourDetailPage: ({ onBack }: {
|
||||||
|
onBack: () => void;
|
||||||
|
}) => React.JSX.Element;
|
||||||
|
|||||||
Vendored
+381
-30
@@ -1,37 +1,388 @@
|
|||||||
"use strict";
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
exports.TourDetailPage = void 0;
|
import { ItineraryTimeline } from './ItineraryTimeline.js';
|
||||||
const jsx_runtime_1 = require("react/jsx-runtime");
|
import { ExpenseManager } from './ExpenseManager.js';
|
||||||
const react_1 = require("react");
|
import { useTourStore } from './useTourStore.js';
|
||||||
const ItineraryTimeline_1 = require("./ItineraryTimeline");
|
import { AddLocationModal } from './AddLocationModal.js';
|
||||||
const ExpenseManager_1 = require("./ExpenseManager");
|
import { AddMemberModal } from './AddMemberModal.js';
|
||||||
const useTourStore_1 = require("./useTourStore");
|
import { ConfirmModal } from './ConfirmModal.js';
|
||||||
const lucide_react_1 = require("lucide-react");
|
import { NotificationModal, useNotificationModal } from './components/NotificationModal.js';
|
||||||
const TourDetailPage = () => {
|
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline } from 'react-leaflet';
|
||||||
const [activeTab, setActiveTab] = (0, react_1.useState)('plan');
|
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||||
const { currentTour, fetchTour, userRole } = (0, useTourStore_1.useTourStore)();
|
const MarkerClusterGroup = _MarkerClusterGroup.default || _MarkerClusterGroup;
|
||||||
(0, react_1.useEffect)(() => {
|
import { useMap } from 'react-leaflet';
|
||||||
fetchTour(1);
|
import { Map as MapIcon, Wallet, Image as ImageIcon, Calendar, Users, ChevronLeft, Settings, Quote, Plus, List, Map as MapIconLucide, MapPin, Flag, Clock, Check, X } from 'lucide-react';
|
||||||
}, [fetchTour]);
|
import L from 'leaflet';
|
||||||
const tabs = [
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
{ id: 'plan', label: 'Lộ trình', icon: lucide_react_1.Map, visible: ['OWNER', 'EDITOR', 'MEMBER_PLAN_ONLY'].includes(userRole || '') },
|
L.Icon.Default.mergeOptions({
|
||||||
{ id: 'expense', label: 'Chi phí', icon: lucide_react_1.Wallet, visible: ['OWNER', 'EDITOR'].includes(userRole || '') },
|
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||||
{ id: 'photo', label: 'Ảnh', icon: lucide_react_1.Image, visible: true },
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||||
].filter(t => t.visible);
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||||
(0, react_1.useEffect)(() => {
|
});
|
||||||
if (userRole === 'MEMBER_PHOTO_ONLY') {
|
const START_ICON = L.divIcon({
|
||||||
setActiveTab('photo');
|
className: 'custom-marker-s',
|
||||||
|
html: `<div class="w-6 h-6 bg-blue-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-[10px] font-black text-white">S</div>`,
|
||||||
|
iconSize: [24, 24],
|
||||||
|
iconAnchor: [12, 12]
|
||||||
|
});
|
||||||
|
const END_ICON = L.divIcon({
|
||||||
|
className: 'custom-marker-e',
|
||||||
|
html: `<div class="w-6 h-6 bg-green-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-[10px] font-black text-white">E</div>`,
|
||||||
|
iconSize: [24, 24],
|
||||||
|
iconAnchor: [12, 12]
|
||||||
|
});
|
||||||
|
const VISIT_ICON = L.divIcon({
|
||||||
|
className: 'custom-marker-v',
|
||||||
|
html: `<div class="w-4 h-4 bg-indigo-500 rounded-full border-2 border-white shadow-md"></div>`,
|
||||||
|
iconSize: [16, 16],
|
||||||
|
iconAnchor: [8, 8]
|
||||||
|
});
|
||||||
|
const MapTourBounds = ({ locations }) => {
|
||||||
|
const map = useMap();
|
||||||
|
const locKey = useMemo(() => JSON.stringify(locations.map(l => [l.latitude, l.longitude])), [locations]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (locations.length > 0) {
|
||||||
|
const bounds = L.latLngBounds(locations.map(l => [l.latitude, l.longitude]));
|
||||||
|
if (locations.length === 1) {
|
||||||
|
map.setView([locations[0].latitude, locations[0].longitude], 15, { animate: true });
|
||||||
}
|
}
|
||||||
}, [userRole]);
|
else {
|
||||||
|
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [locKey, map]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const MapContextMenu = ({ onAction }) => {
|
||||||
|
const [menuPos, setMenuPos] = useState(null);
|
||||||
|
const menuRef = React.useRef(null);
|
||||||
|
const legs = useTourStore(state => state.legs);
|
||||||
|
const setMapCenter = useTourStore(state => state.setMapCenter);
|
||||||
|
useMapEvents({
|
||||||
|
contextmenu: (e) => {
|
||||||
|
if (e.originalEvent) {
|
||||||
|
L.DomEvent.preventDefault(e.originalEvent);
|
||||||
|
L.DomEvent.stopPropagation(e.originalEvent);
|
||||||
|
}
|
||||||
|
console.log(`[MAP] Context menu triggered at: ${e.latlng.lat}, ${e.latlng.lng}`);
|
||||||
|
setMenuPos({ x: e.containerPoint.x, y: e.containerPoint.y, latlng: e.latlng });
|
||||||
|
},
|
||||||
|
moveend: (e) => {
|
||||||
|
const map = e.target;
|
||||||
|
const center = map.getCenter();
|
||||||
|
const zoom = map.getZoom();
|
||||||
|
const coords = [center.lat, center.lng];
|
||||||
|
const currentStored = useTourStore.getState().mapCenter;
|
||||||
|
const diff = Math.abs(currentStored[0] - coords[0]) + Math.abs(currentStored[1] - coords[1]);
|
||||||
|
if (diff > 0.0001) {
|
||||||
|
setMapCenter(coords);
|
||||||
|
}
|
||||||
|
localStorage.setItem('map_view_state', JSON.stringify({ center: coords, zoom }));
|
||||||
|
},
|
||||||
|
click: () => setMenuPos(null),
|
||||||
|
dragstart: () => setMenuPos(null),
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (menuPos && menuRef.current) {
|
||||||
|
L.DomEvent.disableClickPropagation(menuRef.current);
|
||||||
|
L.DomEvent.disableScrollPropagation(menuRef.current);
|
||||||
|
}
|
||||||
|
}, [menuPos]);
|
||||||
|
if (!menuPos)
|
||||||
|
return null;
|
||||||
|
return (_jsxs("div", { ref: menuRef, className: "absolute z-[2000] bg-white rounded-2xl shadow-2xl border border-gray-100 py-2 w-48 animate-in zoom-in-95 duration-200", style: { top: menuPos.y, left: menuPos.x }, onClick: (e) => e.stopPropagation(), onContextMenu: (e) => e.preventDefault(), children: [_jsxs("button", { onClick: () => { onAction('START', menuPos.latlng); setMenuPos(null); }, className: "w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-gray-700 flex items-center gap-2", children: [_jsx("div", { className: "w-2 h-2 rounded-full bg-blue-600" }), " B\u1EAFt \u0111\u1EA7u t\u1EEB \u0111\u00E2y"] }), _jsxs("button", { onClick: () => { onAction('END', menuPos.latlng); setMenuPos(null); }, className: "w-full text-left px-4 py-2 hover:bg-green-50 text-sm font-bold text-gray-700 flex items-center gap-2 border-b border-gray-50", children: [_jsx("div", { className: "w-2 h-2 rounded-full bg-green-600" }), " K\u1EBFt th\u00FAc \u1EDF \u0111\u00E2y"] }), _jsx("div", { className: "px-4 py-2 text-[10px] font-black text-gray-400 uppercase tracking-widest", children: "Th\u00EAm v\u00E0o ch\u1EB7ng" }), legs.map(leg => (_jsxs("button", { onClick: () => { onAction(`ADD_TO_LEG_${leg.id}`, menuPos.latlng); setMenuPos(null); }, className: "w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-medium text-gray-600 truncate", children: ["Ch\u1EB7ng ", leg.sequence, ": ", leg.note || 'Không có ghi chú'] }, leg.id)))] }));
|
||||||
|
};
|
||||||
|
export const TourDetailPage = ({ onBack }) => {
|
||||||
|
const [activeTab, setActiveTab] = useState('plan');
|
||||||
|
const [viewMode, setViewMode] = useState('timeline');
|
||||||
|
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
|
||||||
|
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
|
||||||
|
const [targetLegId, setTargetLegId] = useState(null);
|
||||||
|
const [editingLocation, setEditingLocation] = useState(null);
|
||||||
|
const [selectedMember, setSelectedMember] = useState(null);
|
||||||
|
const [isMemberDetailOpen, setIsMemberDetailOpen] = useState(false);
|
||||||
|
const [joinRequests, setJoinRequests] = useState([]);
|
||||||
|
const [joinRequestActionId, setJoinRequestActionId] = useState(null);
|
||||||
|
const [confirmState, setConfirmState] = useState({ open: false });
|
||||||
|
const { currentTour, legs, fetchTour, fetchPublicTours, publicTours, userRole, mapCenter, setMapCenter, updateTourStartPoint, updateTourEndPoint, initializeLegs, addLocation, addMember, removeMember, fetchJoinRequests, acceptJoinRequest, rejectJoinRequest } = useTourStore();
|
||||||
|
const notificationModal = useNotificationModal();
|
||||||
|
const [initialViewState] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('map_view_state');
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(saved);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const canEdit = ['OWNER', 'MANAGER'].includes(userRole || '');
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentTour && ['OWNER', 'MANAGER'].includes(userRole || '')) {
|
||||||
|
fetchJoinRequests(currentTour.id).then(setJoinRequests).catch(() => setJoinRequests([]));
|
||||||
|
}
|
||||||
|
}, [currentTour, userRole]);
|
||||||
|
const [mapZoom] = useState(initialViewState?.zoom || 13);
|
||||||
|
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialViewState) {
|
||||||
|
setMapCenter(initialViewState.center);
|
||||||
|
}
|
||||||
|
const loadData = async () => {
|
||||||
|
if (publicTours.length === 0) {
|
||||||
|
await fetchPublicTours();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (publicTours.length > 0 && !currentTour) {
|
||||||
|
fetchTour(publicTours[0].id);
|
||||||
|
}
|
||||||
|
}, [publicTours, currentTour, fetchTour]);
|
||||||
|
const handleDeclareLegs = async () => {
|
||||||
|
const countStr = window.prompt("Chuyến đi này bạn muốn chia làm bao nhiêu chặng?", "3");
|
||||||
|
const count = parseInt(countStr || "0");
|
||||||
|
if (count > 0 && currentTour) {
|
||||||
|
await initializeLegs(currentTour.id, count);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleMapAction = async (action, latlng) => {
|
||||||
|
console.log(`[FRONTEND] Triggered ${action} at:`, { lat: latlng.lat, lng: latlng.lng });
|
||||||
|
const currentLegs = useTourStore.getState().legs;
|
||||||
|
if (!currentTour || currentLegs.length === 0) {
|
||||||
|
alert("Tour chưa có chặng nào. Vui lòng tạo chặng (Leg) trước khi thực hiện.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let targetLegId = currentLegs[0].id;
|
||||||
|
let defaultName = "Địa điểm mới";
|
||||||
|
let locationType = 'VISIT';
|
||||||
|
if (action === 'START') {
|
||||||
|
defaultName = "Điểm bắt đầu";
|
||||||
|
locationType = 'MOVE';
|
||||||
|
}
|
||||||
|
if (action === 'END') {
|
||||||
|
targetLegId = currentLegs[currentLegs.length - 1].id;
|
||||||
|
defaultName = "Điểm kết thúc";
|
||||||
|
locationType = 'MOVE';
|
||||||
|
}
|
||||||
|
if (action.startsWith('ADD_TO_LEG_')) {
|
||||||
|
targetLegId = action.replace('ADD_TO_LEG_', '');
|
||||||
|
}
|
||||||
|
let detectedName = "";
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latlng.lat}&lon=${latlng.lng}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const addr = data.address;
|
||||||
|
detectedName = addr.amenity || addr.building || addr.historic || addr.tourist ||
|
||||||
|
addr.shop || addr.office || addr.leisure || addr.attraction ||
|
||||||
|
addr.road || addr.neighbourhood || addr.suburb ||
|
||||||
|
data.display_name?.split(',')[0] || "";
|
||||||
|
console.log(`[FRONTEND] Geocoding Result: "${detectedName}"`);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.warn("[FRONTEND] Reverse Geocoding failed:", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (action === 'START') {
|
||||||
|
const resolvedPlaceName = detectedName || "Điểm xuất phát";
|
||||||
|
console.log(`[FRONTEND] Updating START point to: ${resolvedPlaceName} at`, latlng);
|
||||||
|
await updateTourStartPoint(currentTour.id, {
|
||||||
|
name: resolvedPlaceName,
|
||||||
|
latitude: latlng.lat,
|
||||||
|
longitude: latlng.lng,
|
||||||
|
});
|
||||||
|
console.log("[FRONTEND] START point updated successfully.");
|
||||||
|
}
|
||||||
|
else if (action === 'END') {
|
||||||
|
const finalName = detectedName || "Điểm kết thúc";
|
||||||
|
await updateTourEndPoint(currentTour.id, {
|
||||||
|
name: finalName,
|
||||||
|
latitude: latlng.lat,
|
||||||
|
longitude: latlng.lng,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const name = window.prompt("Xác nhận tên địa điểm tham quan:", detectedName || defaultName);
|
||||||
|
if (!name)
|
||||||
|
return;
|
||||||
|
await addLocation(currentTour.id, {
|
||||||
|
name,
|
||||||
|
address: '',
|
||||||
|
latitude: latlng.lat,
|
||||||
|
longitude: latlng.lng,
|
||||||
|
legId: targetLegId,
|
||||||
|
type: locationType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
if (error.message.includes('Không tìm thấy chặng')) {
|
||||||
|
alert("Lỗi: Bạn cần tạo ít nhất một Chặng (Leg) trước khi xác định điểm Bắt đầu/Kết thúc.");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
alert("Đã xảy ra lỗi: " + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const startPoint = legs[0]?.locations[0];
|
||||||
|
const lastLeg = legs[legs.length - 1];
|
||||||
|
const endPoint = lastLeg?.locations[lastLeg.locations.length - 1];
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: true },
|
||||||
|
{ id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '') },
|
||||||
|
{ id: 'photo', label: 'Ảnh', icon: ImageIcon, visible: true },
|
||||||
|
{ id: 'settings', label: 'Cài đặt', icon: Settings, visible: userRole === 'OWNER' },
|
||||||
|
].filter(t => t.visible);
|
||||||
|
const hasFinanceAccess = ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
||||||
|
const travelQuotes = [
|
||||||
|
"Đừng nghe họ nói, hãy tự mình đi xem.",
|
||||||
|
"Thế giới là một cuốn sách, và ai không đi du lịch thì chỉ mới đọc được một trang.",
|
||||||
|
"Hành trình ngàn dặm bắt đầu từ một bước chân.",
|
||||||
|
"Đi là để trở về, nhưng với một tâm hồn mới."
|
||||||
|
];
|
||||||
|
const randomQuote = useMemo(() => travelQuotes[Math.floor(Math.random() * travelQuotes.length)], []);
|
||||||
const tourInfo = {
|
const tourInfo = {
|
||||||
title: currentTour?.title || "Hành trình khám phá TP.HCM",
|
title: currentTour?.title || "Hành trình khám phá TP.HCM",
|
||||||
date: "20 - 21 Tháng 11, 2023",
|
date: currentTour?.startDate ? `${new Date(currentTour.startDate).toLocaleDateString('vi-VN')} - ${new Date(currentTour.endDate).toLocaleDateString('vi-VN')}` : "Chưa xác định ngày",
|
||||||
members: 5,
|
membersCount: currentTour?.participants?.length || 0,
|
||||||
budget: "2.500.000 VND"
|
budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND",
|
||||||
|
coverImage: currentTour?.photos?.[0]?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
|
||||||
};
|
};
|
||||||
return ((0, jsx_runtime_1.jsxs)("div", { className: "min-h-screen bg-white", children: [(0, jsx_runtime_1.jsxs)("div", { className: "sticky top-0 z-30 bg-white border-b border-gray-100 px-4 py-3 flex items-center justify-between", children: [(0, jsx_runtime_1.jsx)("button", { className: "p-2 hover:bg-gray-100 rounded-full transition-colors", children: (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronLeft, { className: "w-6 h-6 text-gray-600" }) }), (0, jsx_runtime_1.jsx)("h1", { className: "text-lg font-bold text-gray-800 truncate px-4", children: tourInfo.title }), (0, jsx_runtime_1.jsx)("div", { className: "w-10" }), " "] }), (0, jsx_runtime_1.jsx)("div", { className: "bg-blue-600 text-white p-6 pb-20", children: (0, jsx_runtime_1.jsxs)("div", { className: "max-w-2xl mx-auto space-y-4", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex flex-wrap gap-4 text-sm opacity-90", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Calendar, { className: "w-4 h-4 mr-1.5" }), tourInfo.date] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Users, { className: "w-4 h-4 mr-1.5" }), tourInfo.members, " th\u00E0nh vi\u00EAn"] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-baseline gap-2", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-3xl font-bold", children: tourInfo.budget }), (0, jsx_runtime_1.jsx)("span", { className: "text-blue-100 text-sm", children: "d\u1EF1 ki\u1EBFn" })] })] }) }), (0, jsx_runtime_1.jsxs)("div", { className: "max-w-2xl mx-auto -mt-12 px-4 pb-24", children: [(0, jsx_runtime_1.jsx)("div", { className: "bg-white rounded-2xl shadow-xl border border-gray-100 p-1 flex mb-6", children: tabs.map((tab) => ((0, jsx_runtime_1.jsxs)("button", { onClick: () => setActiveTab(tab.id), className: `flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${activeTab === tab.id
|
return (_jsxs("div", { className: "min-h-screen bg-gray-50 pb-20", children: [_jsxs("div", { className: "sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-gray-100 px-4 py-3 flex items-center justify-between", children: [_jsx("button", { onClick: onBack, className: "p-2 hover:bg-gray-100 rounded-full transition-colors", children: _jsx(ChevronLeft, { className: "w-6 h-6 text-gray-600" }) }), _jsx("h1", { className: "text-lg font-bold text-gray-800 truncate px-4", children: tourInfo.title }), _jsx("div", { className: "w-10" }), " "] }), _jsxs("div", { className: "relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end", children: [_jsx("img", { src: tourInfo.coverImage, className: "absolute inset-0 w-full h-full object-cover opacity-60", alt: "Tour Cover" }), _jsx("div", { className: "absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" }), _jsx("div", { className: "relative z-10 p-6 text-white pt-28 pb-20", children: _jsxs("div", { className: "max-w-2xl mx-auto space-y-4", children: [_jsx("h2", { className: "text-3xl font-black tracking-tight drop-shadow-md", children: tourInfo.title }), _jsxs("div", { className: "mt-3 text-[11px] sm:text-sm font-bold text-white bg-black/40 backdrop-blur-md px-4 py-2 rounded-xl border border-white/10 inline-flex items-center max-w-full", children: [_jsx("span", { className: "text-white/60 mr-1", children: "L\u1ED9 tr\u00ECnh:" }), _jsx("span", { className: "text-blue-300", children: "\u0110i\u1EC3m xu\u1EA5t ph\u00E1t:" }), _jsx("span", { className: "ml-1 text-white banner-location-text", title: startPoint?.name, children: startPoint?.name || '...' }), _jsx("span", { className: "mx-2 text-white/30", children: "-" }), _jsx("span", { className: "text-green-300", children: "\u0110i\u1EC3m k\u1EBFt th\u00FAc:" }), _jsx("span", { className: "ml-1 text-white banner-location-text", title: endPoint?.name, children: endPoint?.name || '...' })] }), _jsxs("div", { className: "flex flex-wrap gap-4 text-sm font-medium opacity-90", children: [_jsxs("div", { className: "flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10", children: [_jsx(Calendar, { className: "w-4 h-4 mr-1.5" }), tourInfo.date] }), _jsxs("div", { className: "flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10", children: [_jsx(Users, { className: "w-4 h-4 mr-1.5" }), tourInfo.membersCount, " th\u00E0nh vi\u00EAn"] })] }), _jsxs("div", { className: "flex items-center gap-2 mt-4", children: [_jsxs("div", { className: "flex flex-wrap gap-2", children: [currentTour?.participants?.slice(0, 5).map((p, i) => (_jsx("button", { onClick: () => {
|
||||||
|
setSelectedMember(p);
|
||||||
|
setIsMemberDetailOpen(true);
|
||||||
|
}, className: "w-10 h-10 rounded-full border-2 border-white bg-blue-100 flex items-center justify-center text-blue-600 font-bold overflow-hidden shadow-lg hover:scale-110 transition-transform", title: p.user?.name || p.userId, children: _jsx("img", { src: `https://i.pravatar.cc/100?u=${p.userId}`, alt: "Avatar" }) }, p.userId || i))), joinRequests.slice(0, 3).map((req) => (_jsxs("div", { className: "relative group", children: [_jsx("div", { className: "w-10 h-10 rounded-full border-2 border-amber-400 bg-amber-50 flex items-center justify-center text-amber-700 font-bold overflow-hidden shadow-lg", children: req.user?.name?.charAt(0) || '?' }), _jsxs("div", { className: "absolute -top-1 -right-1 flex", children: [_jsx("button", { type: "button", disabled: joinRequestActionId === req.id, onClick: async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!currentTour)
|
||||||
|
return;
|
||||||
|
setConfirmState({
|
||||||
|
open: true,
|
||||||
|
title: 'Chấp nhận yêu cầu',
|
||||||
|
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`,
|
||||||
|
onConfirm: async () => {
|
||||||
|
setJoinRequestActionId(req.id);
|
||||||
|
try {
|
||||||
|
await acceptJoinRequest(currentTour.id, req.id);
|
||||||
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
notificationModal.openModal('Thông báo', e.message || 'Không thể chấp nhận yêu cầu', 'error');
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setJoinRequestActionId(null);
|
||||||
|
setConfirmState({ open: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, className: "w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50", "aria-label": "Accept", children: "+" }), _jsx("button", { type: "button", disabled: joinRequestActionId === req.id, onClick: async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!currentTour)
|
||||||
|
return;
|
||||||
|
setConfirmState({
|
||||||
|
open: true,
|
||||||
|
title: 'Từ chối yêu cầu',
|
||||||
|
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`,
|
||||||
|
onConfirm: async () => {
|
||||||
|
setJoinRequestActionId(req.id);
|
||||||
|
try {
|
||||||
|
await rejectJoinRequest(currentTour.id, req.id);
|
||||||
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
notificationModal.openModal('Thông báo', e.message || 'Không thể từ chối yêu cầu', 'error');
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setJoinRequestActionId(null);
|
||||||
|
setConfirmState({ open: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, className: "w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1", "aria-label": "Reject", children: "x" })] })] }, req.id))), tourInfo.membersCount > 5 && (_jsxs("div", { className: "w-10 h-10 rounded-full border-2 border-white bg-gray-800 flex items-center justify-center text-[10px] font-bold text-white shadow-lg", children: ["+", tourInfo.membersCount - 5] }))] }), _jsx("button", { onClick: () => {
|
||||||
|
if (!currentTour)
|
||||||
|
return;
|
||||||
|
if (canEdit)
|
||||||
|
setIsAddMemberOpen(true);
|
||||||
|
}, disabled: !canEdit, className: `p-2 rounded-full border backdrop-blur-sm transition-all ml-2 ${canEdit ? 'bg-white/10 hover:bg-white/20 border-white/20' : 'bg-white/5 border-white/10 opacity-50'}`, children: _jsx(Plus, { className: "w-4 h-4" }) })] })] }) })] }), _jsx("div", { className: "max-w-2xl mx-auto -mt-10 px-4 relative z-10", children: _jsx("div", { className: `rounded-3xl p-6 shadow-2xl transition-all duration-500 ${hasFinanceAccess ? 'bg-indigo-600 text-white shadow-indigo-200' : 'bg-white text-gray-600 border border-gray-100'}`, children: _jsx("div", { className: "flex justify-between items-center", children: hasFinanceAccess ? (_jsxs(_Fragment, { children: [_jsxs("div", { children: [_jsx("p", { className: "text-indigo-100 text-xs font-black uppercase tracking-widest mb-1", children: "T\u1ED5ng chi ti\u00EAu hi\u1EC7n t\u1EA1i" }), _jsx("h3", { className: "text-3xl font-black", children: tourInfo.budget })] }), _jsx("div", { className: "p-4 bg-white/10 rounded-2xl backdrop-blur-md", children: _jsx(Wallet, { className: "w-8 h-8" }) })] })) : (_jsxs("div", { className: "flex items-start gap-4 py-2", children: [_jsx(Quote, { className: "w-8 h-8 text-blue-500 opacity-30 flex-shrink-0" }), _jsxs("p", { className: "italic text-lg font-medium leading-relaxed", children: ["\"", randomQuote, "\""] })] })) }) }) }), _jsxs("div", { className: "max-w-2xl mx-auto mt-4 px-4 grid grid-cols-1 sm:grid-cols-2 gap-3 animate-in fade-in slide-in-from-top-2 duration-500", children: [startPoint && (_jsxs("div", { className: "bg-white p-4 rounded-2xl border border-blue-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow", children: [_jsx("div", { className: "w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center text-blue-600 shadow-inner", children: _jsx(MapPin, { className: "w-5 h-5" }) }), _jsxs("div", { className: "overflow-hidden", children: [_jsx("p", { className: "text-[10px] font-black text-blue-400 uppercase tracking-widest mb-0.5", children: "\u0110i\u1EC3m xu\u1EA5t ph\u00E1t" }), _jsx("p", { className: "text-sm font-bold text-gray-800 truncate", children: startPoint.name })] })] })), endPoint && (_jsxs("div", { className: "bg-white p-4 rounded-2xl border border-green-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow", children: [_jsx("div", { className: "w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center text-green-600 shadow-inner", children: _jsx(Flag, { className: "w-5 h-5" }) }), _jsxs("div", { className: "overflow-hidden", children: [_jsx("p", { className: "text-[10px] font-black text-green-400 uppercase tracking-widest mb-0.5", children: "\u0110i\u1EC3m k\u1EBFt th\u00FAc" }), _jsx("p", { className: "text-sm font-bold text-gray-800 truncate", children: endPoint.name })] })] }))] }), _jsxs("div", { className: `${activeTab === 'plan' && viewMode === 'map' ? 'w-full' : 'max-w-2xl mx-auto'} mt-6 px-4 pb-24`, children: [_jsx("div", { className: "bg-white rounded-2xl shadow-lg border border-gray-100 p-1 flex mb-6 sticky top-20 z-20", children: tabs.map((tab) => (_jsxs("button", { onClick: () => setActiveTab(tab.id), className: `flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${activeTab === tab.id
|
||||||
? 'bg-blue-50 text-blue-600 shadow-sm'
|
? 'bg-blue-50 text-blue-600 shadow-sm'
|
||||||
: 'text-gray-400 hover:text-gray-600'}`, children: [(0, jsx_runtime_1.jsx)(tab.icon, { className: `w-4 h-4 mr-2 ${activeTab === tab.id ? 'animate-pulse' : ''}` }), tab.label] }, tab.id))) }), (0, jsx_runtime_1.jsxs)("div", { className: "transition-opacity duration-300", children: [activeTab === 'plan' && ((0, jsx_runtime_1.jsx)("div", { className: "animate-in fade-in slide-in-from-bottom-4", children: (0, jsx_runtime_1.jsx)(ItineraryTimeline_1.ItineraryTimeline, {}) })), activeTab === 'expense' && ((0, jsx_runtime_1.jsx)("div", { className: "animate-in fade-in slide-in-from-bottom-4", children: (0, jsx_runtime_1.jsx)(ExpenseManager_1.ExpenseManager, {}) })), activeTab === 'photo' && ((0, jsx_runtime_1.jsx)("div", { className: "grid grid-cols-3 gap-2 animate-in fade-in", children: [1, 2, 3, 4, 5, 6].map((i) => ((0, jsx_runtime_1.jsxs)("div", { className: "aspect-square bg-gray-100 rounded-lg overflow-hidden relative group", children: [(0, jsx_runtime_1.jsx)("div", { className: "absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" }), (0, jsx_runtime_1.jsx)("img", { src: `https://picsum.photos/seed/${i + 10}/400/400`, alt: "Tour photo", className: "w-full h-full object-cover" })] }, i))) }))] })] }), (0, jsx_runtime_1.jsx)("div", { className: "fixed bottom-6 left-1/2 -translate-x-1/2 z-40", children: (0, jsx_runtime_1.jsx)("button", { className: "bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold", children: activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh' }) })] }));
|
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'}`, children: [_jsx(tab.icon, { className: `w-4 h-4 mr-2 ${activeTab === tab.id ? 'scale-110' : ''}` }), tab.label] }, tab.id))) }), _jsxs("div", { className: "transition-opacity duration-300", children: [activeTab === 'plan' && (_jsxs("div", { className: "animate-in fade-in slide-in-from-bottom-2", children: [_jsx("div", { className: "flex justify-center mb-6", children: _jsxs("div", { className: "bg-gray-100 p-1 rounded-2xl flex gap-1", children: [_jsxs("button", { onClick: () => setViewMode('timeline'), className: `flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'timeline' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`, children: [_jsx(List, { className: "w-3.5 h-3.5" }), " Danh s\u00E1ch"] }), _jsxs("button", { onClick: () => setViewMode('map'), className: `flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'map' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`, children: [_jsx(MapIconLucide, { className: "w-3.5 h-3.5" }), " B\u1EA3n \u0111\u1ED3"] })] }) }), viewMode === 'timeline' ? (_jsx(ItineraryTimeline, { onAddLocation: (legId) => {
|
||||||
|
setTargetLegId(legId);
|
||||||
|
setEditingLocation(null);
|
||||||
|
setIsAddLocationOpen(true);
|
||||||
|
}, onEditLocation: (loc) => {
|
||||||
|
setEditingLocation(loc);
|
||||||
|
setTargetLegId(loc.legId);
|
||||||
|
setMapCenter([loc.latitude, loc.longitude]);
|
||||||
|
setIsAddLocationOpen(true);
|
||||||
|
} })) : (_jsxs("div", { className: "h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative", children: [_jsxs(MapContainer, { center: initialViewState?.center || mapCenter, zoom: mapZoom, className: "h-full w-full", preferCanvas: true, children: [_jsx(TileLayer, { url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" }), canEdit && _jsx(MapContextMenu, { onAction: handleMapAction }), _jsx(MapTourBounds, { locations: allLocations }), allLocations.length > 1 && (_jsx(Polyline, { positions: allLocations.map(l => [l.latitude, l.longitude]), color: "#3b82f6", weight: 3, dashArray: "5, 10", smoothFactor: 1.5 })), _jsx(MarkerClusterGroup, { chunkedLoading: true, children: legs.flatMap(l => l.locations).map((loc) => {
|
||||||
|
const isStart = startPoint?.id === loc.id;
|
||||||
|
const isEnd = endPoint?.id === loc.id;
|
||||||
|
const icon = isStart ? START_ICON : isEnd ? END_ICON : VISIT_ICON;
|
||||||
|
return (_jsx(Marker, { position: [loc.latitude, loc.longitude], icon: icon, children: _jsxs(Popup, { children: [_jsx("div", { className: "font-bold", children: loc.name }), _jsx("div", { className: "text-xs text-gray-500", children: loc.type })] }) }, loc.id));
|
||||||
|
}) })] }), _jsx("div", { className: "absolute top-4 left-4 z-[1000] bg-white/90 backdrop-blur-md p-3 rounded-2xl text-[10px] font-bold text-gray-500 shadow-lg border border-white", children: "M\u1EB9o: Nh\u1EA5n gi\u1EEF (Mobile) ho\u1EB7c Chu\u1ED9t ph\u1EA3i \u0111\u1EC3 ghim \u0111\u1ECBa \u0111i\u1EC3m" })] }))] })), activeTab === 'expense' && (_jsx("div", { className: "animate-in fade-in slide-in-from-bottom-4", children: _jsx(ExpenseManager, {}) })), activeTab === 'photo' && (_jsx("div", { className: "grid grid-cols-3 gap-1.5 animate-in fade-in", children: [1, 2, 3, 4, 5, 6].map((i) => (_jsxs("div", { className: "aspect-square bg-gray-200 rounded-xl overflow-hidden relative group border border-white", children: [_jsx("div", { className: "absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" }), _jsx("img", { src: `https://picsum.photos/seed/${i + 10}/400/400`, alt: "Tour photo", className: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" })] }, i))) })), activeTab === 'settings' && (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "p-6 bg-white rounded-3xl border border-dashed border-gray-200 animate-in zoom-in-95", children: [_jsxs("div", { className: "flex items-center gap-3 mb-4", children: [_jsx(Clock, { className: "w-6 h-6 text-blue-500" }), _jsx("h3", { className: "text-lg font-bold text-gray-900", children: "Y\u00EAu c\u1EA7u tham gia" }), _jsxs("span", { className: "text-xs font-bold text-gray-500 bg-gray-100 px-2 py-1 rounded-full", children: [joinRequests.length, " \u0111ang ch\u1EDD"] })] }), _jsxs("div", { className: "space-y-2", children: [joinRequests.map((req) => (_jsxs("div", { className: "flex items-center justify-between gap-3 p-3 rounded-2xl border border-gray-100 bg-white", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "w-9 h-9 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-700 font-bold text-sm", children: req.user?.name?.charAt(0) || '?' }), _jsxs("div", { children: [_jsx("div", { className: "text-sm font-bold text-gray-800", children: req.user?.name || req.userId }), _jsxs("div", { className: "text-[11px] text-gray-500", children: ["\u0110\u01B0\u1EE3c m\u1EDDi b\u1EDFi ", req.requestedBy?.name, " \u2022 ", new Date(req.createdAt).toLocaleString('vi-VN')] })] })] }), _jsxs("div", { className: "flex gap-2", children: [_jsx("button", { disabled: joinRequestActionId === req.id, onClick: async () => {
|
||||||
|
if (!currentTour)
|
||||||
|
return;
|
||||||
|
setConfirmState({
|
||||||
|
open: true,
|
||||||
|
title: 'Chấp nhận yêu cầu',
|
||||||
|
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`,
|
||||||
|
onConfirm: async () => {
|
||||||
|
setJoinRequestActionId(req.id);
|
||||||
|
try {
|
||||||
|
await acceptJoinRequest(currentTour.id, req.id);
|
||||||
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
notificationModal.openModal('Thông báo', e.message || 'Không thể chấp nhận yêu cầu', 'error');
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setJoinRequestActionId(null);
|
||||||
|
setConfirmState({ open: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, className: "p-2 rounded-xl bg-green-50 text-green-700 hover:bg-green-100 disabled:opacity-50", "aria-label": "Accept", children: _jsx(Check, { className: "w-4 h-4" }) }), _jsx("button", { disabled: joinRequestActionId === req.id, onClick: async () => {
|
||||||
|
if (!currentTour)
|
||||||
|
return;
|
||||||
|
setConfirmState({
|
||||||
|
open: true,
|
||||||
|
title: 'Từ chối yêu cầu',
|
||||||
|
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`,
|
||||||
|
onConfirm: async () => {
|
||||||
|
setJoinRequestActionId(req.id);
|
||||||
|
try {
|
||||||
|
await rejectJoinRequest(currentTour.id, req.id);
|
||||||
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
notificationModal.openModal('Thông báo', e.message || 'Không thể từ chối yêu cầu', 'error');
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setJoinRequestActionId(null);
|
||||||
|
setConfirmState({ open: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, className: "p-2 rounded-xl bg-red-50 text-red-700 hover:bg-red-100 disabled:opacity-50", "aria-label": "Reject", children: _jsx(X, { className: "w-4 h-4" }) })] })] }, req.id))), joinRequests.length === 0 && (_jsx("div", { className: "text-center py-8 text-sm text-gray-500", children: "Kh\u00F4ng c\u00F3 y\u00EAu c\u1EA7u tham gia n\u00E0o \u0111ang ch\u1EDD ph\u00EA duy\u1EC7t." }))] })] }), _jsxs("div", { className: "p-8 text-center bg-gray-50 rounded-3xl border border-dashed border-gray-200", children: [_jsx(Settings, { className: "w-10 h-10 text-gray-300 mx-auto mb-3" }), _jsx("p", { className: "text-gray-500 font-medium", children: "T\u00EDnh n\u0103ng c\u00E0i \u0111\u1EB7t kh\u00E1c \u0111ang \u0111\u01B0\u1EE3c c\u1EADp nh\u1EADt..." })] })] }))] })] }), canEdit && (_jsx("div", { className: "fixed bottom-6 left-1/2 -translate-x-1/2 z-40", children: _jsx("button", { onClick: () => {
|
||||||
|
setTargetLegId(null);
|
||||||
|
setEditingLocation(null);
|
||||||
|
if (activeTab === 'plan')
|
||||||
|
setIsAddLocationOpen(true);
|
||||||
|
}, className: "bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold", children: activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh' }) })), currentTour && (_jsx(AddMemberModal, { isOpen: isAddMemberOpen, onClose: () => setIsAddMemberOpen(false), tourId: currentTour.id, participants: currentTour.participants || [], joinRequests: joinRequests, onRemoveMember: (userId) => removeMember(currentTour.id, userId), onMemberAdded: () => fetchTour(currentTour.id), userRole: userRole || undefined })), currentTour && (_jsx(AddLocationModal, { isOpen: isAddLocationOpen, onClose: () => setIsAddLocationOpen(false), initialLegId: targetLegId || undefined, editingLocation: editingLocation, tourId: currentTour.id })), isMemberDetailOpen && selectedMember && (_jsxs("div", { className: "fixed inset-0 z-[2100] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/60 backdrop-blur-sm", onClick: () => setIsMemberDetailOpen(false) }), _jsxs("div", { className: "relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "w-12 h-12 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold", children: selectedMember.user?.name?.charAt(0) || '?' }), _jsxs("div", { children: [_jsx("div", { className: "text-base font-bold text-gray-900", children: selectedMember.user?.name || 'Chưa đặt tên' }), _jsx("div", { className: "text-xs text-gray-500", children: selectedMember.user?.email }), _jsx("div", { className: "text-[10px] font-semibold text-gray-500", children: selectedMember.role })] })] }), (selectedMember.user?.phone || selectedMember.user?.address) && (_jsxs("div", { className: "mt-3 text-xs text-gray-600 space-y-1", children: [selectedMember.user?.phone && _jsxs("div", { children: ["\uD83D\uDCDE ", selectedMember.user.phone] }), selectedMember.user?.address && _jsxs("div", { children: ["\uD83D\uDCCD ", selectedMember.user.address] })] })), _jsxs("div", { className: "mt-4 flex justify-end gap-2", children: [_jsx("button", { onClick: () => setIsMemberDetailOpen(false), className: "px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100", children: "\u0110\u00F3ng" }), canEdit && selectedMember.role !== 'OWNER' && (_jsx("button", { onClick: async () => {
|
||||||
|
if (!currentTour || !selectedMember)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
await removeMember(currentTour.id, selectedMember.userId);
|
||||||
|
setIsMemberDetailOpen(false);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
notificationModal.openModal('Thông báo', 'Không thể xóa thành viên', 'error');
|
||||||
|
}
|
||||||
|
}, className: "px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold", children: "X\u00F3a" })), canEdit && selectedMember.role === 'OWNER' && (_jsx("button", { onClick: () => {
|
||||||
|
setIsMemberDetailOpen(false);
|
||||||
|
setIsAddMemberOpen(true);
|
||||||
|
}, className: "px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl text-sm font-bold", children: "M\u1EDDi th\u00EAm ng\u01B0\u1EDDi" }))] })] })] })), _jsx(ConfirmModal, { isOpen: confirmState.open, title: confirmState.title, message: confirmState.message, onConfirm: () => confirmState.onConfirm?.(), onCancel: () => setConfirmState({ open: false }) }), _jsx(NotificationModal, { isOpen: notificationModal.modalState?.isOpen ?? false, title: notificationModal.modalState?.title, message: notificationModal.modalState?.message, type: notificationModal.modalState?.type, onConfirm: () => notificationModal.closeModal() })] }));
|
||||||
};
|
};
|
||||||
exports.TourDetailPage = TourDetailPage;
|
|
||||||
//# sourceMappingURL=TourDetailPage.js.map
|
//# sourceMappingURL=TourDetailPage.js.map
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react';
|
||||||
|
interface UserManagementModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
export declare const UserManagementModal: React.FC<UserManagementModalProps>;
|
||||||
|
export {};
|
||||||
Vendored
+67
@@ -0,0 +1,67 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { X, User, Trash2, Shield, ShieldAlert, Lock, Unlock, Loader2 } from 'lucide-react';
|
||||||
|
export const UserManagementModal = ({ isOpen, onClose }) => {
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/users`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Không thể tải danh sách người dùng');
|
||||||
|
const data = await response.json();
|
||||||
|
setUsers(data);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen)
|
||||||
|
fetchUsers();
|
||||||
|
}, [isOpen]);
|
||||||
|
const handleToggleBlock = async (id) => {
|
||||||
|
try {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
await fetch(`${API_BASE}/api/v1/users/block/${id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||||
|
});
|
||||||
|
fetchUsers();
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
alert('Lỗi khi thay đổi trạng thái block');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
if (!confirm('Bạn có chắc chắn muốn xóa người dùng này?'))
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/users/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message);
|
||||||
|
}
|
||||||
|
fetchUsers();
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!isOpen)
|
||||||
|
return null;
|
||||||
|
return (_jsxs("div", { className: "fixed inset-0 z-[2000] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/60 backdrop-blur-sm", onClick: onClose }), _jsxs("div", { className: "relative w-full max-w-4xl bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]", children: [_jsxs("div", { className: "p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50/50", children: [_jsxs("div", { children: [_jsxs("h2", { className: "text-2xl font-bold text-gray-900 flex items-center gap-2", children: [_jsx(Shield, { className: "w-6 h-6 text-blue-600" }), " Qu\u1EA3n l\u00FD ng\u01B0\u1EDDi d\u00F9ng"] }), _jsx("p", { className: "text-sm text-gray-500", children: "Qu\u1EA3n tr\u1ECB vi\u00EAn c\u00F3 quy\u1EC1n th\u00EAm, s\u1EEDa, x\u00F3a ho\u1EB7c kh\u00F3a t\u00E0i kho\u1EA3n." })] }), _jsx("button", { onClick: onClose, className: "p-2 hover:bg-gray-200 rounded-full transition-colors", children: _jsx(X, { className: "w-6 h-6 text-gray-400" }) })] }), _jsx("div", { className: "flex-1 overflow-y-auto p-6", children: loading ? (_jsx("div", { className: "flex justify-center py-20", children: _jsx(Loader2, { className: "w-10 h-10 animate-spin text-blue-600" }) })) : error ? (_jsx("div", { className: "p-4 bg-red-50 text-red-600 rounded-xl font-bold", children: error })) : (_jsxs("table", { className: "w-full text-left border-collapse", children: [_jsx("thead", { children: _jsxs("tr", { className: "text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100", children: [_jsx("th", { className: "pb-4 font-bold px-2", children: "Ng\u01B0\u1EDDi d\u00F9ng" }), _jsx("th", { className: "pb-4 font-bold", children: "Vai tr\u00F2" }), _jsx("th", { className: "pb-4 font-bold", children: "Tr\u1EA1ng th\u00E1i" }), _jsx("th", { className: "pb-4 font-bold text-right", children: "Thao t\u00E1c" })] }) }), _jsx("tbody", { className: "divide-y divide-gray-50", children: users.map(u => (_jsxs("tr", { className: "group hover:bg-gray-50/50 transition-colors", children: [_jsx("td", { className: "py-4 px-2", children: _jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold", children: u.name?.charAt(0) || _jsx(User, { className: "w-5 h-5" }) }), _jsxs("div", { children: [_jsx("div", { className: "font-bold text-gray-900", children: u.name || 'N/A' }), _jsx("div", { className: "text-xs text-gray-400", children: u.email })] })] }) }), _jsx("td", { className: "py-4", children: u.isAdmin ? (_jsx("span", { className: "px-2 py-1 bg-purple-50 text-purple-600 text-[10px] font-black rounded-md border border-purple-100", children: "ADMIN" })) : (_jsx("span", { className: "px-2 py-1 bg-gray-50 text-gray-500 text-[10px] font-black rounded-md border border-gray-100", children: "USER" })) }), _jsx("td", { className: "py-4", children: u.isBlocked ? (_jsxs("span", { className: "flex items-center gap-1 text-red-500 text-xs font-bold", children: [_jsx(ShieldAlert, { className: "w-3 h-3" }), " \u0110\u00E3 kh\u00F3a"] })) : (_jsx("span", { className: "text-green-500 text-xs font-bold", children: "\u0110ang ho\u1EA1t \u0111\u1ED9ng" })) }), _jsx("td", { className: "py-4 text-right", children: _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx("button", { onClick: () => handleToggleBlock(u.id), className: `p-2 rounded-xl transition-all ${u.isBlocked ? 'bg-green-50 text-green-600 hover:bg-green-100' : 'bg-amber-50 text-amber-600 hover:bg-amber-100'}`, title: u.isBlocked ? 'Mở khóa' : 'Khóa tài khoản', children: u.isBlocked ? _jsx(Unlock, { className: "w-4 h-4" }) : _jsx(Lock, { className: "w-4 h-4" }) }), _jsx("button", { onClick: () => handleDelete(u.id), className: "p-2 bg-red-50 text-red-600 rounded-xl hover:bg-red-100 transition-all", title: "X\u00F3a ng\u01B0\u1EDDi d\u00F9ng", children: _jsx(Trash2, { className: "w-4 h-4" }) })] }) })] }, u.id))) })] })) })] })] }));
|
||||||
|
};
|
||||||
|
//# sourceMappingURL=UserManagementModal.js.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"UserManagementModal.js","sourceRoot":"","sources":["../UserManagementModal.tsx"],"names":[],"mappings":"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"}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
import { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service.js';
|
||||||
|
export declare class AdminGuard implements CanActivate {
|
||||||
|
private prisma;
|
||||||
|
constructor(prisma: PrismaService);
|
||||||
|
canActivate(context: ExecutionContext): Promise<boolean>;
|
||||||
|
}
|
||||||
Vendored
+37
@@ -0,0 +1,37 @@
|
|||||||
|
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||||
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||||
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||||
|
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||||
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||||
|
};
|
||||||
|
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||||
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||||
|
};
|
||||||
|
import { Injectable, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service.js';
|
||||||
|
let AdminGuard = class AdminGuard {
|
||||||
|
constructor(prisma) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
}
|
||||||
|
async canActivate(context) {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
if (!user || !user.id) {
|
||||||
|
throw new ForbiddenException('Yêu cầu xác thực không hợp lệ. Vui lòng đăng nhập.');
|
||||||
|
}
|
||||||
|
const dbUser = await this.prisma.user.findUnique({
|
||||||
|
where: { id: user.id },
|
||||||
|
select: { isAdmin: true, isBlocked: true },
|
||||||
|
});
|
||||||
|
if (!dbUser || !dbUser.isAdmin || dbUser.isBlocked) {
|
||||||
|
throw new ForbiddenException('Truy cập bị từ chối. Bạn không có quyền quản trị viên hệ thống.');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
AdminGuard = __decorate([
|
||||||
|
Injectable(),
|
||||||
|
__metadata("design:paramtypes", [PrismaService])
|
||||||
|
], AdminGuard);
|
||||||
|
export { AdminGuard };
|
||||||
|
//# sourceMappingURL=admin.guard.js.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"admin.guard.js","sourceRoot":"","sources":["../admin.guard.ts"],"names":[],"mappings":";;;;;;;;;AAAA,OAAO,EAAE,UAAU,EAAiC,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAC/F,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAG7C,IAAM,UAAU,GAAhB,MAAM,UAAU;IACrB,YAAoB,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAE7C,KAAK,CAAC,WAAW,CAAC,OAAyB;QACzC,MAAM,OAAO,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,UAAU,EAAE,CAAC;QAGpD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;QAE1B,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC;YACtB,MAAM,IAAI,kBAAkB,CAAC,oDAAoD,CAAC,CAAC;QACrF,CAAC;QAGD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC;YAC/C,KAAK,EAAE,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE;YACtB,MAAM,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE;SAC3C,CAAC,CAAC;QAEH,IAAI,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,IAAI,MAAM,CAAC,SAAS,EAAE,CAAC;YACnD,MAAM,IAAI,kBAAkB,CAAC,iEAAiE,CAAC,CAAC;QAClG,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAzBY,UAAU;IADtB,UAAU,EAAE;qCAEiB,aAAa;GAD9B,UAAU,CAyBtB"}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
export interface NotificationModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
type?: 'info' | 'success' | 'warning' | 'error';
|
||||||
|
confirmButtonText?: string;
|
||||||
|
cancelButtonText?: string;
|
||||||
|
}
|
||||||
|
export declare const NotificationModal: React.FC<NotificationModalProps>;
|
||||||
|
export declare const useNotificationModal: () => {
|
||||||
|
modalState: {
|
||||||
|
isOpen: boolean;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
type?: "info" | "success" | "warning" | "error";
|
||||||
|
onConfirm?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
};
|
||||||
|
openModal: (title: string, message: string, type?: "info" | "success" | "warning" | "error", onConfirm?: () => void, onCancel?: () => void) => () => void;
|
||||||
|
closeModal: () => void;
|
||||||
|
};
|
||||||
|
export default NotificationModal;
|
||||||
Vendored
+81
@@ -0,0 +1,81 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useState } from 'react';
|
||||||
|
const Icons = {
|
||||||
|
info: (props) => (_jsxs("svg", { ...props, viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "2" }), _jsx("path", { d: "M12 16v-4", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" }), _jsx("path", { d: "M12 8h.01", stroke: "currentColor", strokeWidth: "3", strokeLinecap: "round" })] })),
|
||||||
|
success: (props) => (_jsxs("svg", { ...props, viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "2" }), _jsx("path", { d: "M8 12l2.5 2.5L15.5 9", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" })] })),
|
||||||
|
warning: (props) => (_jsxs("svg", { ...props, viewBox: "0 0 24 24", fill: "none", children: [_jsx("path", { d: "M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z", stroke: "currentColor", strokeWidth: "2" }), _jsx("line", { x1: "12", y1: "9", x2: "12", y2: "13", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" }), _jsx("circle", { cx: "12", cy: "17", r: "1", fill: "currentColor" })] })),
|
||||||
|
error: (props) => (_jsxs("svg", { ...props, viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "2" }), _jsx("line", { x1: "15", y1: "9", x2: "9", y2: "15", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" }), _jsx("line", { x1: "9", y1: "9", x2: "15", y2: "15", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" })] })),
|
||||||
|
};
|
||||||
|
const defaultProps = {
|
||||||
|
title: 'Thông báo',
|
||||||
|
type: 'info',
|
||||||
|
confirmButtonText: 'OK',
|
||||||
|
cancelButtonText: 'Hủy',
|
||||||
|
};
|
||||||
|
export const NotificationModal = ({ isOpen, title = defaultProps.title, message, onConfirm, onCancel, type = defaultProps.type, confirmButtonText = defaultProps.confirmButtonText, cancelButtonText = defaultProps.cancelButtonText, }) => {
|
||||||
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
|
const getTypeStyles = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'success':
|
||||||
|
return { bg: '#d4edda', border: '#c3e6cb', text: '#155724' };
|
||||||
|
case 'warning':
|
||||||
|
return { bg: '#fff3cd', border: '#ffeeba', text: '#856404' };
|
||||||
|
case 'error':
|
||||||
|
return { bg: '#f8d7da', border: '#f5c6cb', text: '#721c24' };
|
||||||
|
default:
|
||||||
|
return { bg: '#e2e3e5', border: '#d6d8db', text: '#383d41' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const styles = getTypeStyles();
|
||||||
|
const getAnimationClass = () => {
|
||||||
|
if (!isOpen)
|
||||||
|
return 'opacity-0 translate-y-4';
|
||||||
|
if (isAnimating && onCancel)
|
||||||
|
return 'animate-fade-out';
|
||||||
|
return 'animate-fade-in';
|
||||||
|
};
|
||||||
|
const handleConfirm = () => {
|
||||||
|
setIsAnimating(true);
|
||||||
|
onConfirm?.();
|
||||||
|
setTimeout(() => setIsAnimating(false), 300);
|
||||||
|
};
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsAnimating(true);
|
||||||
|
onCancel?.();
|
||||||
|
setTimeout(() => setIsAnimating(false), 300);
|
||||||
|
};
|
||||||
|
if (!isOpen)
|
||||||
|
return null;
|
||||||
|
return (_jsx("div", { className: "fixed inset-0 bg-black/50 flex items-center justify-center z-[100] p-4", children: _jsxs("div", { className: `bg-white rounded-lg shadow-xl max-w-md w-full transform transition-all duration-300 ${getAnimationClass()}`, role: "alertdialog", "aria-modal": "true", "aria-labelledby": "modal-title", "aria-describedby": "modal-message", children: [_jsx("div", { className: `p-6 border-b ${styles.border}`, children: _jsxs("div", { className: "flex items-center gap-3", children: [type === 'success' && _jsx(Icons.success, { className: "w-5 h-5 text-green-600" }), type === 'warning' && _jsx(Icons.warning, { className: "w-5 h-5 text-yellow-600" }), type === 'error' && _jsx(Icons.error, { className: "w-5 h-5 text-red-600" }), type === 'info' && _jsx(Icons.info, { className: "w-5 h-5 text-blue-600" }), _jsx("h2", { id: "modal-title", className: `text-xl font-semibold ${styles.text}`, children: title })] }) }), _jsx("div", { className: "p-6", children: _jsx("p", { id: "modal-message", className: "text-gray-700 leading-relaxed", children: message }) }), _jsxs("div", { className: `px-6 py-4 flex justify-end gap-3 border-t ${styles.border}`, children: [onCancel && (_jsx("button", { onClick: handleCancel, className: "px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-md transition-colors", children: cancelButtonText })), onConfirm && (_jsx("button", { onClick: handleConfirm, className: `px-4 py-2 text-white rounded-md font-medium transition-colors ${type === 'error'
|
||||||
|
? 'bg-red-600 hover:bg-red-700'
|
||||||
|
: 'bg-blue-600 hover:bg-blue-700'}`, children: confirmButtonText }))] })] }) }));
|
||||||
|
};
|
||||||
|
export const useNotificationModal = () => {
|
||||||
|
const [modalState, setModalState] = useState(null);
|
||||||
|
const openModal = (title, message, type = 'info', onConfirm, onCancel) => {
|
||||||
|
setModalState({
|
||||||
|
isOpen: true,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
});
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (onCancel) {
|
||||||
|
setModalState((prev) => ({ ...prev, isOpen: false }));
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
};
|
||||||
|
const closeModal = () => {
|
||||||
|
setModalState((prev) => prev ? { ...prev, isOpen: false } : null);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
modalState,
|
||||||
|
openModal,
|
||||||
|
closeModal,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export default NotificationModal;
|
||||||
|
//# sourceMappingURL=NotificationModal.js.map
|
||||||
+1
File diff suppressed because one or more lines are too long
Vendored
+5
-9
@@ -1,17 +1,14 @@
|
|||||||
"use strict";
|
import { BadRequestException } from '@nestjs/common';
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
export class ExpenseEngine {
|
||||||
exports.ExpenseEngine = void 0;
|
|
||||||
const common_1 = require("@nestjs/common");
|
|
||||||
class ExpenseEngine {
|
|
||||||
calculateSplit(totalCost, adultCount, childCount, childDiscountPercent) {
|
calculateSplit(totalCost, adultCount, childCount, childDiscountPercent) {
|
||||||
if (totalCost < 0 || adultCount < 0 || childCount < 0 || childDiscountPercent < 0) {
|
if (totalCost < 0 || adultCount < 0 || childCount < 0 || childDiscountPercent < 0) {
|
||||||
throw new common_1.BadRequestException('Dữ liệu đầu vào không được là số âm.');
|
throw new BadRequestException('Dữ liệu đầu vào không được là số âm.');
|
||||||
}
|
}
|
||||||
if (childDiscountPercent > 100) {
|
if (childDiscountPercent > 100) {
|
||||||
throw new common_1.BadRequestException('Phần trăm giảm giá không thể vượt quá 100%.');
|
throw new BadRequestException('Phần trăm giảm giá không thể vượt quá 100%.');
|
||||||
}
|
}
|
||||||
if (adultCount + childCount === 0) {
|
if (adultCount + childCount === 0) {
|
||||||
throw new common_1.BadRequestException('Số lượng người tham gia phải lớn hơn 0.');
|
throw new BadRequestException('Số lượng người tham gia phải lớn hơn 0.');
|
||||||
}
|
}
|
||||||
const discountRate = childDiscountPercent / 100;
|
const discountRate = childDiscountPercent / 100;
|
||||||
const childRateFactor = 1 - discountRate;
|
const childRateFactor = 1 - discountRate;
|
||||||
@@ -34,5 +31,4 @@ class ExpenseEngine {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
exports.ExpenseEngine = ExpenseEngine;
|
|
||||||
//# sourceMappingURL=expense-engine.service.js.map
|
//# sourceMappingURL=expense-engine.service.js.map
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"expense-engine.service.js","sourceRoot":"","sources":["../expense-engine.service.ts"],"names":[],"mappings":";;;AAAA,2CAAqD;AAMrD,MAAa,aAAa;IAQxB,cAAc,CACZ,SAAiB,EACjB,UAAkB,EAClB,UAAkB,EAClB,oBAA4B;QAG5B,IAAI,SAAS,GAAG,CAAC,IAAI,UAAU,GAAG,CAAC,IAAI,UAAU,GAAG,CAAC,IAAI,oBAAoB,GAAG,CAAC,EAAE,CAAC;YAClF,MAAM,IAAI,4BAAmB,CAAC,sCAAsC,CAAC,CAAC;QACxE,CAAC;QAED,IAAI,oBAAoB,GAAG,GAAG,EAAE,CAAC;YAC/B,MAAM,IAAI,4BAAmB,CAAC,6CAA6C,CAAC,CAAC;QAC/E,CAAC;QAGD,IAAI,UAAU,GAAG,UAAU,KAAK,CAAC,EAAE,CAAC;YAClC,MAAM,IAAI,4BAAmB,CAAC,yCAAyC,CAAC,CAAC;QAC3E,CAAC;QAED,MAAM,YAAY,GAAG,oBAAoB,GAAG,GAAG,CAAC;QAChD,MAAM,eAAe,GAAG,CAAC,GAAG,YAAY,CAAC;QAQzC,MAAM,wBAAwB,GAAG,UAAU,GAAG,CAAC,UAAU,GAAG,eAAe,CAAC,CAAC;QAE7E,MAAM,aAAa,GAAG,SAAS,GAAG,wBAAwB,CAAC;QAC3D,MAAM,aAAa,GAAG,aAAa,GAAG,eAAe,CAAC;QAGtD,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;QAC3C,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;QAE3C,MAAM,WAAW,GAAG,QAAQ,GAAG,UAAU,CAAC;QAC1C,MAAM,aAAa,GAAG,QAAQ,GAAG,UAAU,CAAC;QAC5C,MAAM,SAAS,GAAG,WAAW,GAAG,aAAa,CAAC;QAE9C,OAAO;YACL,WAAW,EAAE,SAAS;YACtB,WAAW,EAAE,SAAS;YACtB,QAAQ;YACR,QAAQ;YACR,kBAAkB,EAAE,SAAS,GAAG,SAAS;YACzC,WAAW;YACX,aAAa;SACd,CAAC;IACJ,CAAC;CACF;AA5DD,sCA4DC"}
|
{"version":3,"file":"expense-engine.service.js","sourceRoot":"","sources":["../expense-engine.service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAMrD,MAAM,OAAO,aAAa;IAQxB,cAAc,CACZ,SAAiB,EACjB,UAAkB,EAClB,UAAkB,EAClB,oBAA4B;QAG5B,IAAI,SAAS,GAAG,CAAC,IAAI,UAAU,GAAG,CAAC,IAAI,UAAU,GAAG,CAAC,IAAI,oBAAoB,GAAG,CAAC,EAAE,CAAC;YAClF,MAAM,IAAI,mBAAmB,CAAC,sCAAsC,CAAC,CAAC;QACxE,CAAC;QAED,IAAI,oBAAoB,GAAG,GAAG,EAAE,CAAC;YAC/B,MAAM,IAAI,mBAAmB,CAAC,6CAA6C,CAAC,CAAC;QAC/E,CAAC;QAGD,IAAI,UAAU,GAAG,UAAU,KAAK,CAAC,EAAE,CAAC;YAClC,MAAM,IAAI,mBAAmB,CAAC,yCAAyC,CAAC,CAAC;QAC3E,CAAC;QAED,MAAM,YAAY,GAAG,oBAAoB,GAAG,GAAG,CAAC;QAChD,MAAM,eAAe,GAAG,CAAC,GAAG,YAAY,CAAC;QAQzC,MAAM,wBAAwB,GAAG,UAAU,GAAG,CAAC,UAAU,GAAG,eAAe,CAAC,CAAC;QAE7E,MAAM,aAAa,GAAG,SAAS,GAAG,wBAAwB,CAAC;QAC3D,MAAM,aAAa,GAAG,aAAa,GAAG,eAAe,CAAC;QAGtD,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;QAC3C,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;QAE3C,MAAM,WAAW,GAAG,QAAQ,GAAG,UAAU,CAAC;QAC1C,MAAM,aAAa,GAAG,QAAQ,GAAG,UAAU,CAAC;QAC5C,MAAM,SAAS,GAAG,WAAW,GAAG,aAAa,CAAC;QAE9C,OAAO;YACL,WAAW,EAAE,SAAS;YACtB,WAAW,EAAE,SAAS;YACtB,QAAQ;YACR,QAAQ;YACR,kBAAkB,EAAE,SAAS,GAAG,SAAS;YACzC,WAAW;YACX,aAAa;SACd,CAAC;IACJ,CAAC;CACF"}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
import './index.css';
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
import { jsx as _jsx } from "react/jsx-runtime";
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App.js';
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(_jsx(React.StrictMode, { children: _jsx(App, {}) }));
|
||||||
|
//# sourceMappingURL=index.js.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"index.js","sourceRoot":"","sources":["../index.tsx"],"names":[],"mappings":";AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAC1B,OAAO,QAAQ,MAAM,kBAAkB,CAAC;AACxC,OAAO,aAAa,CAAC;AACrB,OAAO,GAAG,MAAM,UAAU,CAAC;AAE3B,QAAQ,CAAC,UAAU,CAAC,QAAQ,CAAC,cAAc,CAAC,MAAM,CAAE,CAAC,CAAC,MAAM,CAC1D,KAAC,KAAK,CAAC,UAAU,cACf,KAAC,GAAG,KAAG,GACU,CACpB,CAAC"}
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
declare const JwtAuthGuard_base: import("@nestjs/passport").Type<import("@nestjs/passport").IAuthGuard>;
|
||||||
|
export declare class JwtAuthGuard extends JwtAuthGuard_base {
|
||||||
|
}
|
||||||
|
export {};
|
||||||
Vendored
+15
@@ -0,0 +1,15 @@
|
|||||||
|
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||||
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||||
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||||
|
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||||
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||||
|
};
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
let JwtAuthGuard = class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
|
};
|
||||||
|
JwtAuthGuard = __decorate([
|
||||||
|
Injectable()
|
||||||
|
], JwtAuthGuard);
|
||||||
|
export { JwtAuthGuard };
|
||||||
|
//# sourceMappingURL=jwt-auth.guard.js.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"jwt-auth.guard.js","sourceRoot":"","sources":["../jwt-auth.guard.ts"],"names":[],"mappings":";;;;;;AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAC5C,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAGtC,IAAM,YAAY,GAAlB,MAAM,YAAa,SAAQ,SAAS,CAAC,KAAK,CAAC;CAAG,CAAA;AAAxC,YAAY;IADxB,UAAU,EAAE;GACA,YAAY,CAA4B"}
|
||||||
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
import { Strategy } from 'passport-jwt';
|
||||||
|
import { PrismaService } from './prisma.service.js';
|
||||||
|
declare const JwtStrategy_base: new (...args: [opt: import("passport-jwt").StrategyOptionsWithRequest] | [opt: import("passport-jwt").StrategyOptionsWithoutRequest]) => Strategy & {
|
||||||
|
validate(...args: any[]): unknown;
|
||||||
|
};
|
||||||
|
export declare class JwtStrategy extends JwtStrategy_base {
|
||||||
|
private prisma;
|
||||||
|
constructor(prisma: PrismaService);
|
||||||
|
validate(payload: any): Promise<{
|
||||||
|
name: string | null;
|
||||||
|
address: string | null;
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
phone: string | null;
|
||||||
|
passwordHash: string;
|
||||||
|
avatar: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isBlocked: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
export {};
|
||||||
Vendored
+38
@@ -0,0 +1,38 @@
|
|||||||
|
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||||
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||||
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||||
|
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||||
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||||
|
};
|
||||||
|
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||||
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||||
|
};
|
||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { PrismaService } from './prisma.service.js';
|
||||||
|
let JwtStrategy = class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(prisma) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: process.env.JWT_SECRET || 'super-secret',
|
||||||
|
});
|
||||||
|
this.prisma = prisma;
|
||||||
|
}
|
||||||
|
async validate(payload) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: payload.sub },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('Người dùng không tồn tại hoặc phiên làm việc hết hạn');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
JwtStrategy = __decorate([
|
||||||
|
Injectable(),
|
||||||
|
__metadata("design:paramtypes", [PrismaService])
|
||||||
|
], JwtStrategy);
|
||||||
|
export { JwtStrategy };
|
||||||
|
//# sourceMappingURL=jwt.strategy.js.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"jwt.strategy.js","sourceRoot":"","sources":["../jwt.strategy.ts"],"names":[],"mappings":";;;;;;;;;AAAA,OAAO,EAAE,UAAU,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AACnE,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AACpD,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAG7C,IAAM,WAAW,GAAjB,MAAM,WAAY,SAAQ,gBAAgB,CAAC,QAAQ,CAAC;IACzD,YAAoB,MAAqB;QACvC,KAAK,CAAC;YACJ,cAAc,EAAE,UAAU,CAAC,2BAA2B,EAAE;YACxD,gBAAgB,EAAE,KAAK;YACvB,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,cAAc;SACtD,CAAC,CAAC;QALe,WAAM,GAAN,MAAM,CAAe;IAMzC,CAAC;IAED,KAAK,CAAC,QAAQ,CAAC,OAAY;QACzB,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC;YAC7C,KAAK,EAAE,EAAE,EAAE,EAAE,OAAO,CAAC,GAAG,EAAE;SAC3B,CAAC,CAAC;QAEH,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,MAAM,IAAI,qBAAqB,CAAC,sDAAsD,CAAC,CAAC;QAC1F,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AApBY,WAAW;IADvB,UAAU,EAAE;qCAEiB,aAAa;GAD9B,WAAW,CAoBvB"}
|
||||||
Vendored
+1
@@ -1 +1,2 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
|||||||
Vendored
+916
-23
@@ -1,4 +1,3 @@
|
|||||||
"use strict";
|
|
||||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||||
@@ -11,71 +10,965 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|||||||
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
||||||
return function (target, key) { decorator(target, key, paramIndex); }
|
return function (target, key) { decorator(target, key, paramIndex); }
|
||||||
};
|
};
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
import 'reflect-metadata';
|
||||||
const core_1 = require("@nestjs/core");
|
import { NestFactory } from '@nestjs/core';
|
||||||
const common_1 = require("@nestjs/common");
|
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException } from '@nestjs/common';
|
||||||
const prisma_service_1 = require("./prisma.service");
|
import { PrismaService } from './prisma.service.js';
|
||||||
require("dotenv/config");
|
import 'dotenv/config';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { JwtModule, JwtService } from '@nestjs/jwt';
|
||||||
|
import { JwtAuthGuard } from './jwt-auth.guard.js';
|
||||||
|
import { JwtStrategy } from './jwt.strategy.js';
|
||||||
|
import { TourRoleGuard } from './rbac.middleware.js';
|
||||||
let AppController = class AppController {
|
let AppController = class AppController {
|
||||||
getHello() {
|
getHello() {
|
||||||
return 'Travel Planning API is running!';
|
return 'Travel Planning API is running!';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
__decorate([
|
__decorate([
|
||||||
(0, common_1.Get)(),
|
Get(),
|
||||||
__metadata("design:type", Function),
|
__metadata("design:type", Function),
|
||||||
__metadata("design:paramtypes", []),
|
__metadata("design:paramtypes", []),
|
||||||
__metadata("design:returntype", String)
|
__metadata("design:returntype", String)
|
||||||
], AppController.prototype, "getHello", null);
|
], AppController.prototype, "getHello", null);
|
||||||
AppController = __decorate([
|
AppController = __decorate([
|
||||||
(0, common_1.Controller)()
|
Controller()
|
||||||
], AppController);
|
], AppController);
|
||||||
|
let AuthController = class AuthController {
|
||||||
|
constructor(prisma, jwtService) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
this.jwtService = jwtService;
|
||||||
|
}
|
||||||
|
async getStatus() {
|
||||||
|
const userCount = await this.prisma.user.count();
|
||||||
|
console.log(`[Status Check] Users found: ${userCount}`);
|
||||||
|
return { isInitialSetup: userCount === 0 };
|
||||||
|
}
|
||||||
|
async login(body) {
|
||||||
|
const { email, password } = body;
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||||
|
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
|
||||||
|
throw new UnauthorizedException('Email hoặc mật khẩu không chính xác');
|
||||||
|
}
|
||||||
|
if (user.isBlocked) {
|
||||||
|
throw new UnauthorizedException('Tài khoản của bạn đã bị khóa. Vui lòng liên hệ quản trị viên.');
|
||||||
|
}
|
||||||
|
const payload = { email: user.email, sub: user.id };
|
||||||
|
return {
|
||||||
|
access_token: this.jwtService.sign(payload),
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
isAdmin: user.isAdmin,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async signup(body) {
|
||||||
|
const { email, password, name, phone, address } = body;
|
||||||
|
const existingUser = await this.prisma.user.findUnique({ where: { email } });
|
||||||
|
if (existingUser)
|
||||||
|
throw new BadRequestException('Email đã được sử dụng');
|
||||||
|
const userCount = await this.prisma.user.count();
|
||||||
|
const shouldBeAdmin = userCount === 0;
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
return this.prisma.user.create({
|
||||||
|
data: { email, passwordHash, name, phone, address, isAdmin: shouldBeAdmin },
|
||||||
|
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
__decorate([
|
||||||
|
Get('status'),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", []),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], AuthController.prototype, "getStatus", null);
|
||||||
|
__decorate([
|
||||||
|
Post('login'),
|
||||||
|
__param(0, Body()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], AuthController.prototype, "login", null);
|
||||||
|
__decorate([
|
||||||
|
Post('signup'),
|
||||||
|
__param(0, Body()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], AuthController.prototype, "signup", null);
|
||||||
|
AuthController = __decorate([
|
||||||
|
Controller('v1/auth'),
|
||||||
|
__metadata("design:paramtypes", [PrismaService, JwtService])
|
||||||
|
], AuthController);
|
||||||
let TourController = class TourController {
|
let TourController = class TourController {
|
||||||
constructor(prisma) {
|
constructor(prisma) {
|
||||||
this.prisma = prisma;
|
this.prisma = prisma;
|
||||||
}
|
}
|
||||||
|
async createTour(body, req) {
|
||||||
|
const { title, startDate, endDate } = body;
|
||||||
|
return this.prisma.tour.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
startDate: startDate ? new Date(startDate) : null,
|
||||||
|
endDate: endDate ? new Date(endDate) : null,
|
||||||
|
createdById: req.user.id,
|
||||||
|
participants: {
|
||||||
|
create: {
|
||||||
|
userId: req.user.id,
|
||||||
|
role: 'OWNER'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legs: {
|
||||||
|
create: {
|
||||||
|
sequence: 1,
|
||||||
|
note: 'Chặng khởi đầu'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
participants: {
|
||||||
|
include: { user: { select: { id: true, name: true, email: true } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async addLocation(tourId, body, req) {
|
||||||
|
const legId = body.legId;
|
||||||
|
console.log(`[USER ACTION] User ${req.user.id} added a NEW VISIT point to Tour ${tourId}: "${body.name}"`);
|
||||||
|
const leg = legId
|
||||||
|
? await this.prisma.leg.findUnique({ where: { id: legId } })
|
||||||
|
: await this.prisma.leg.findFirst({ where: { tourId } });
|
||||||
|
if (!leg)
|
||||||
|
throw new NotFoundException('Không tìm thấy chặng phù hợp để thêm địa điểm');
|
||||||
|
return this.prisma.location.create({
|
||||||
|
data: {
|
||||||
|
name: body.name,
|
||||||
|
address: body.address,
|
||||||
|
latitude: body.latitude,
|
||||||
|
longitude: body.longitude,
|
||||||
|
type: body.type,
|
||||||
|
legId: leg.id,
|
||||||
|
plannedStart: body.plannedStart ? new Date(body.plannedStart) : null,
|
||||||
|
plannedEnd: body.plannedEnd ? new Date(body.plannedEnd) : null,
|
||||||
|
}
|
||||||
|
}).then(async (loc) => {
|
||||||
|
if (body.expenseAmount && Number(body.expenseAmount) > 0) {
|
||||||
|
await this.prisma.expense.create({
|
||||||
|
data: {
|
||||||
|
amount: Number(body.expenseAmount),
|
||||||
|
category: body.expenseCategory || 'OTHER',
|
||||||
|
locationId: loc.id,
|
||||||
|
legId: loc.legId,
|
||||||
|
description: body.expenseDescription || `Chi phí tại ${loc.name}`,
|
||||||
|
note: body.expenseNote || null,
|
||||||
|
paidById: body.paidById || null,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return loc;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async updateTourStartPoint(tourId, body, req) {
|
||||||
|
const { latitude, longitude, name } = body;
|
||||||
|
console.log(`[USER ACTION] User ${req.user.id} set START point for Tour ${tourId}: "${name}" at [${latitude}, ${longitude}]`);
|
||||||
|
await this.prisma.location.deleteMany({
|
||||||
|
where: {
|
||||||
|
leg: { tourId: tourId },
|
||||||
|
plannedStart: new Date(0)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const firstLeg = await this.prisma.leg.findFirst({
|
||||||
|
where: { tourId },
|
||||||
|
orderBy: { sequence: 'asc' }
|
||||||
|
});
|
||||||
|
if (!firstLeg)
|
||||||
|
throw new NotFoundException('Không tìm thấy chặng đầu tiên cho Tour này');
|
||||||
|
return this.prisma.location.create({
|
||||||
|
data: {
|
||||||
|
name: name || 'Điểm xuất phát',
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
type: 'MOVE',
|
||||||
|
legId: firstLeg.id,
|
||||||
|
plannedStart: new Date(0),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async updateTourEndPoint(tourId, body, req) {
|
||||||
|
const { latitude, longitude, name } = body;
|
||||||
|
console.log(`[USER ACTION] User ${req.user.id} set END point for Tour ${tourId}: "${name}" at [${latitude}, ${longitude}]`);
|
||||||
|
await this.prisma.location.deleteMany({
|
||||||
|
where: {
|
||||||
|
leg: { tourId: tourId },
|
||||||
|
plannedEnd: new Date(0)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const lastLeg = await this.prisma.leg.findFirst({
|
||||||
|
where: { tourId },
|
||||||
|
orderBy: { sequence: 'desc' }
|
||||||
|
});
|
||||||
|
if (!lastLeg)
|
||||||
|
throw new NotFoundException('Không tìm thấy chặng cuối cùng cho Tour này');
|
||||||
|
return this.prisma.location.create({
|
||||||
|
data: {
|
||||||
|
name: name || 'Điểm kết thúc',
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
type: 'MOVE',
|
||||||
|
legId: lastLeg.id,
|
||||||
|
plannedEnd: new Date(0),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async initializeLegs(tourId, body) {
|
||||||
|
const { count } = body;
|
||||||
|
if (count <= 0 || count > 20)
|
||||||
|
throw new BadRequestException('Số lượng chặng không hợp lệ (1-20)');
|
||||||
|
const existingLegs = await this.prisma.leg.findMany({
|
||||||
|
where: { tourId },
|
||||||
|
orderBy: { sequence: 'asc' }
|
||||||
|
});
|
||||||
|
const needed = count - existingLegs.length;
|
||||||
|
if (needed > 0) {
|
||||||
|
const createData = Array.from({ length: needed }).map((_, i) => ({
|
||||||
|
tourId,
|
||||||
|
sequence: existingLegs.length + i + 1,
|
||||||
|
note: `Chặng ${existingLegs.length + i + 1}`
|
||||||
|
}));
|
||||||
|
await this.prisma.leg.createMany({ data: createData });
|
||||||
|
}
|
||||||
|
const allLegs = await this.prisma.leg.findMany({
|
||||||
|
where: { tourId },
|
||||||
|
orderBy: { sequence: 'asc' }
|
||||||
|
});
|
||||||
|
const lastLeg = allLegs[allLegs.length - 1];
|
||||||
|
const endPoint = await this.prisma.location.findFirst({
|
||||||
|
where: { leg: { tourId }, plannedEnd: new Date(0) }
|
||||||
|
});
|
||||||
|
if (endPoint && lastLeg && endPoint.legId !== lastLeg.id) {
|
||||||
|
await this.prisma.location.update({
|
||||||
|
where: { id: endPoint.id },
|
||||||
|
data: { legId: lastLeg.id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return allLegs;
|
||||||
|
}
|
||||||
|
async addLeg(tourId, body) {
|
||||||
|
const tour = await this.prisma.tour.findUnique({
|
||||||
|
where: { id: tourId },
|
||||||
|
include: { legs: true }
|
||||||
|
});
|
||||||
|
if (!tour)
|
||||||
|
throw new NotFoundException('Không tìm thấy tour');
|
||||||
|
return this.prisma.leg.create({
|
||||||
|
data: {
|
||||||
|
tourId,
|
||||||
|
sequence: tour.legs.length + 1,
|
||||||
|
note: body.note || `Chặng ${tour.legs.length + 1}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async updateTour(id, body) {
|
||||||
|
return this.prisma.tour.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
title: body.title,
|
||||||
|
startDate: body.startDate ? new Date(body.startDate) : undefined,
|
||||||
|
endDate: body.endDate ? new Date(body.endDate) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async deleteTour(id) {
|
||||||
|
await this.prisma.tour.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
async getPublicTours(req) {
|
||||||
|
return this.prisma.tour.findMany({
|
||||||
|
where: {
|
||||||
|
participants: {
|
||||||
|
some: { userId: req.user.id }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
take: 20,
|
||||||
|
include: {
|
||||||
|
photos: { take: 1 },
|
||||||
|
legs: {
|
||||||
|
orderBy: { sequence: 'asc' },
|
||||||
|
include: {
|
||||||
|
locations: { orderBy: { plannedStart: 'asc' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
async getTourDetails(id) {
|
async getTourDetails(id) {
|
||||||
const tour = await this.prisma.tour.findUnique({
|
const tour = await this.prisma.tour.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
members: true,
|
participants: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: { id: true, name: true, email: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
photos: true,
|
photos: true,
|
||||||
legs: {
|
legs: {
|
||||||
orderBy: { sequenceNumber: 'asc' },
|
orderBy: { sequence: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
places: { orderBy: { sequenceInLeg: 'asc' } },
|
locations: { orderBy: { plannedStart: 'asc' } },
|
||||||
expenses: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!tour)
|
if (!tour)
|
||||||
throw new common_1.NotFoundException(`Không tìm thấy Tour với ID ${id}`);
|
throw new NotFoundException(`Không tìm thấy Tour với ID ${id}`);
|
||||||
return tour;
|
return tour;
|
||||||
}
|
}
|
||||||
|
async addMember(tourId, body, req) {
|
||||||
|
const validRoles = ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY'];
|
||||||
|
const role = validRoles.includes(body.role) ? body.role : 'MEMBER';
|
||||||
|
const participation = await this.prisma.tourParticipant.findUnique({
|
||||||
|
where: { tourId_userId: { tourId, userId: body.userId } },
|
||||||
|
});
|
||||||
|
if (participation) {
|
||||||
|
return this.prisma.tourParticipant.update({
|
||||||
|
where: { tourId_userId: { tourId, userId: body.userId } },
|
||||||
|
data: { role },
|
||||||
|
include: { user: { select: { id: true, name: true, email: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let currentRole = req.user.tourParticipation?.role;
|
||||||
|
if (!currentRole) {
|
||||||
|
const participation = await this.prisma.tourParticipant.findUnique({
|
||||||
|
where: { tourId_userId: { tourId, userId: req.user.id } },
|
||||||
|
});
|
||||||
|
currentRole = participation?.role;
|
||||||
|
}
|
||||||
|
if (!currentRole || !(currentRole === 'OWNER' || currentRole === 'MANAGER')) {
|
||||||
|
const joinRequest = await this.prisma.joinRequest.create({
|
||||||
|
data: {
|
||||||
|
tourId,
|
||||||
|
userId: body.userId,
|
||||||
|
requestedById: req.user.id,
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
requestedBy: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ...joinRequest, pendingApproval: true };
|
||||||
|
}
|
||||||
|
return this.prisma.tourParticipant.create({
|
||||||
|
data: {
|
||||||
|
tourId,
|
||||||
|
userId: body.userId,
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
include: { user: { select: { id: true, name: true, email: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async getJoinRequests(tourId, req) {
|
||||||
|
const requests = await this.prisma.joinRequest.findMany({
|
||||||
|
where: { tourId, status: 'PENDING' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
requestedBy: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return requests;
|
||||||
|
}
|
||||||
|
async createJoinRequest(tourId, body, req) {
|
||||||
|
const requestingUserId = body.userId || req.user.id;
|
||||||
|
const existingParticipation = await this.prisma.tourParticipant.findUnique({
|
||||||
|
where: { tourId_userId: { tourId, userId: requestingUserId } },
|
||||||
|
});
|
||||||
|
if (existingParticipation) {
|
||||||
|
throw new BadRequestException('Người dùng này đã là thành viên của tour.');
|
||||||
|
}
|
||||||
|
const pendingRequest = await this.prisma.joinRequest.findFirst({
|
||||||
|
where: { tourId, userId: requestingUserId, status: 'PENDING' },
|
||||||
|
});
|
||||||
|
if (pendingRequest) {
|
||||||
|
return pendingRequest;
|
||||||
|
}
|
||||||
|
const joinRequest = await this.prisma.joinRequest.create({
|
||||||
|
data: {
|
||||||
|
tourId,
|
||||||
|
userId: requestingUserId,
|
||||||
|
requestedById: req.user.id,
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
requestedBy: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return joinRequest;
|
||||||
|
}
|
||||||
|
async acceptJoinRequest(tourId, requestId, req) {
|
||||||
|
let role = req.user.tourParticipation?.role;
|
||||||
|
if (!role) {
|
||||||
|
const participation = await this.prisma.tourParticipant.findUnique({
|
||||||
|
where: { tourId_userId: { tourId, userId: req.user.id } },
|
||||||
|
});
|
||||||
|
role = participation?.role;
|
||||||
|
}
|
||||||
|
if (!role || !(role === 'OWNER' || role === 'MANAGER')) {
|
||||||
|
throw new ForbiddenException('Bạn không có quyền chấp nhận yêu cầu tham gia.');
|
||||||
|
}
|
||||||
|
const joinRequest = await this.prisma.joinRequest.findUnique({
|
||||||
|
where: { id: requestId },
|
||||||
|
});
|
||||||
|
if (!joinRequest || joinRequest.tourId !== tourId) {
|
||||||
|
throw new NotFoundException('Không tìm thấy yêu cầu tham gia.');
|
||||||
|
}
|
||||||
|
if (joinRequest.status !== 'PENDING') {
|
||||||
|
throw new BadRequestException('Yêu cầu này đã được xử lý trước đó.');
|
||||||
|
}
|
||||||
|
const existing = await this.prisma.tourParticipant.findUnique({
|
||||||
|
where: { tourId_userId: { tourId, userId: joinRequest.userId } },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
await this.prisma.joinRequest.update({
|
||||||
|
where: { id: requestId },
|
||||||
|
data: { status: 'REJECTED' },
|
||||||
|
});
|
||||||
|
return { success: true, message: 'Người dùng đã là thành viên, yêu cầu đã bị từ chối.' };
|
||||||
|
}
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.tourParticipant.create({
|
||||||
|
data: {
|
||||||
|
tourId,
|
||||||
|
userId: joinRequest.userId,
|
||||||
|
role: 'MEMBER',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.joinRequest.update({
|
||||||
|
where: { id: requestId },
|
||||||
|
data: { status: 'ACCEPTED' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { success: true, message: 'Đã chấp nhận yêu cầu tham gia.' };
|
||||||
|
}
|
||||||
|
async rejectJoinRequest(tourId, requestId, req) {
|
||||||
|
let role = req.user.tourParticipation?.role;
|
||||||
|
if (!role) {
|
||||||
|
const participation = await this.prisma.tourParticipant.findUnique({
|
||||||
|
where: { tourId_userId: { tourId, userId: req.user.id } },
|
||||||
|
});
|
||||||
|
role = participation?.role;
|
||||||
|
}
|
||||||
|
if (!role || !(role === 'OWNER' || role === 'MANAGER')) {
|
||||||
|
throw new ForbiddenException('Bạn không có quyền từ chối yêu cầu tham gia.');
|
||||||
|
}
|
||||||
|
const joinRequest = await this.prisma.joinRequest.findUnique({
|
||||||
|
where: { id: requestId },
|
||||||
|
});
|
||||||
|
if (!joinRequest || joinRequest.tourId !== tourId) {
|
||||||
|
throw new NotFoundException('Không tìm thấy yêu cầu tham gia.');
|
||||||
|
}
|
||||||
|
if (joinRequest.status !== 'PENDING') {
|
||||||
|
throw new BadRequestException('Yêu cầu này đã được xử lý trước đó.');
|
||||||
|
}
|
||||||
|
await this.prisma.joinRequest.update({
|
||||||
|
where: { id: requestId },
|
||||||
|
data: { status: 'REJECTED' },
|
||||||
|
});
|
||||||
|
return { success: true, message: 'Đã từ chối yêu cầu tham gia.' };
|
||||||
|
}
|
||||||
|
async removeMember(tourId, userId) {
|
||||||
|
const participation = await this.prisma.tourParticipant.findUnique({
|
||||||
|
where: { tourId_userId: { tourId, userId } },
|
||||||
|
});
|
||||||
|
if (!participation) {
|
||||||
|
throw new NotFoundException('Thành viên này không có trong tour');
|
||||||
|
}
|
||||||
|
await this.prisma.tourParticipant.delete({
|
||||||
|
where: { tourId_userId: { tourId, userId } },
|
||||||
|
});
|
||||||
|
return { message: 'Đã xóa thành viên khỏi tour' };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
__decorate([
|
__decorate([
|
||||||
(0, common_1.Get)(':id'),
|
UseGuards(JwtAuthGuard),
|
||||||
__param(0, (0, common_1.Param)('id', common_1.ParseIntPipe)),
|
Post(),
|
||||||
|
__param(0, Body()),
|
||||||
|
__param(1, Req()),
|
||||||
__metadata("design:type", Function),
|
__metadata("design:type", Function),
|
||||||
__metadata("design:paramtypes", [Number]),
|
__metadata("design:paramtypes", [Object, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "createTour", null);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||||
|
Post(':tourId/locations'),
|
||||||
|
__param(0, Param('tourId', ParseUUIDPipe)),
|
||||||
|
__param(1, Body()),
|
||||||
|
__param(2, Req()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, Object, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "addLocation", null);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||||
|
Post(':tourId/start-point'),
|
||||||
|
__param(0, Param('tourId', ParseUUIDPipe)),
|
||||||
|
__param(1, Body()),
|
||||||
|
__param(2, Req()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, Object, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "updateTourStartPoint", null);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||||
|
Post(':tourId/end-point'),
|
||||||
|
__param(0, Param('tourId', ParseUUIDPipe)),
|
||||||
|
__param(1, Body()),
|
||||||
|
__param(2, Req()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, Object, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "updateTourEndPoint", null);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||||
|
Post(':tourId/legs/batch'),
|
||||||
|
__param(0, Param('tourId', ParseUUIDPipe)),
|
||||||
|
__param(1, Body()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "initializeLegs", null);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||||
|
Post(':tourId/legs'),
|
||||||
|
__param(0, Param('tourId', ParseUUIDPipe)),
|
||||||
|
__param(1, Body()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "addLeg", null);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||||
|
Patch(':id'),
|
||||||
|
__param(0, Param('id', ParseUUIDPipe)),
|
||||||
|
__param(1, Body()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "updateTour", null);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||||
|
Delete(':id'),
|
||||||
|
__param(0, Param('id', ParseUUIDPipe)),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "deleteTour", null);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard),
|
||||||
|
Get('explore'),
|
||||||
|
__param(0, Req()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "getPublicTours", null);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||||
|
Get(':id'),
|
||||||
|
__param(0, Param('id', ParseUUIDPipe)),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String]),
|
||||||
__metadata("design:returntype", Promise)
|
__metadata("design:returntype", Promise)
|
||||||
], TourController.prototype, "getTourDetails", null);
|
], TourController.prototype, "getTourDetails", null);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||||
|
Post(':tourId/members'),
|
||||||
|
__param(0, Param('tourId', ParseUUIDPipe)),
|
||||||
|
__param(1, Body()),
|
||||||
|
__param(2, Req()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, Object, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "addMember", null);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||||
|
Get(':tourId/join-requests'),
|
||||||
|
__param(0, Param('tourId', ParseUUIDPipe)),
|
||||||
|
__param(1, Req()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "getJoinRequests", null);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||||
|
Post(':tourId/join-requests'),
|
||||||
|
__param(0, Param('tourId', ParseUUIDPipe)),
|
||||||
|
__param(1, Body()),
|
||||||
|
__param(2, Req()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, Object, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "createJoinRequest", null);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||||
|
Post(':tourId/join-requests/:requestId/accept'),
|
||||||
|
__param(0, Param('tourId', ParseUUIDPipe)),
|
||||||
|
__param(1, Param('requestId')),
|
||||||
|
__param(2, Req()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, String, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "acceptJoinRequest", null);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||||
|
Post(':tourId/join-requests/:requestId/reject'),
|
||||||
|
__param(0, Param('tourId', ParseUUIDPipe)),
|
||||||
|
__param(1, Param('requestId')),
|
||||||
|
__param(2, Req()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, String, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "rejectJoinRequest", null);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||||
|
Delete(':tourId/members/:userId'),
|
||||||
|
__param(0, Param('tourId', ParseUUIDPipe)),
|
||||||
|
__param(1, Param('userId', ParseUUIDPipe)),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, String]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "removeMember", null);
|
||||||
TourController = __decorate([
|
TourController = __decorate([
|
||||||
(0, common_1.Controller)('api/v1/tours'),
|
Controller('v1/tours'),
|
||||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
__metadata("design:paramtypes", [PrismaService])
|
||||||
], TourController);
|
], TourController);
|
||||||
|
let LocationController = class LocationController {
|
||||||
|
constructor(prisma) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
}
|
||||||
|
async updateLocation(id, body) {
|
||||||
|
const { expenseAmount, expenseCategory, ...data } = body;
|
||||||
|
const location = await this.prisma.location.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
address: data.address,
|
||||||
|
latitude: data.latitude,
|
||||||
|
longitude: data.longitude,
|
||||||
|
type: data.type,
|
||||||
|
plannedStart: data.plannedStart ? new Date(data.plannedStart) : undefined,
|
||||||
|
plannedEnd: data.plannedEnd ? new Date(data.plannedEnd) : undefined,
|
||||||
|
status: data.status,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (expenseAmount !== undefined) {
|
||||||
|
const amount = Number(expenseAmount);
|
||||||
|
const existingExpense = await this.prisma.expense.findFirst({
|
||||||
|
where: { locationId: id }
|
||||||
|
});
|
||||||
|
if (existingExpense) {
|
||||||
|
await this.prisma.expense.update({
|
||||||
|
where: { id: existingExpense.id },
|
||||||
|
data: { amount, category: expenseCategory || 'OTHER' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (amount > 0) {
|
||||||
|
await this.prisma.expense.create({
|
||||||
|
data: {
|
||||||
|
amount,
|
||||||
|
category: expenseCategory || 'OTHER',
|
||||||
|
locationId: id,
|
||||||
|
legId: location.legId,
|
||||||
|
description: `Chi phí tại ${location.name}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
async deleteLocation(id) {
|
||||||
|
await this.prisma.location.delete({ where: { id } });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
__decorate([
|
||||||
|
Patch(':id'),
|
||||||
|
__param(0, Param('id', ParseUUIDPipe)),
|
||||||
|
__param(1, Body()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], LocationController.prototype, "updateLocation", null);
|
||||||
|
__decorate([
|
||||||
|
Delete(':id'),
|
||||||
|
__param(0, Param('id', ParseUUIDPipe)),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], LocationController.prototype, "deleteLocation", null);
|
||||||
|
LocationController = __decorate([
|
||||||
|
Controller('v1/locations'),
|
||||||
|
UseGuards(JwtAuthGuard),
|
||||||
|
__metadata("design:paramtypes", [PrismaService])
|
||||||
|
], LocationController);
|
||||||
|
let LegController = class LegController {
|
||||||
|
constructor(prisma) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
}
|
||||||
|
async updateLeg(id, body) {
|
||||||
|
return this.prisma.leg.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
note: body.note,
|
||||||
|
sequence: body.sequence
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async deleteLeg(id) {
|
||||||
|
const leg = await this.prisma.leg.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { _count: { select: { locations: true } } }
|
||||||
|
});
|
||||||
|
if (leg?._count.locations && leg._count.locations > 0) {
|
||||||
|
throw new BadRequestException('Không thể xóa chặng đang có địa điểm. Hãy xóa địa điểm trước.');
|
||||||
|
}
|
||||||
|
await this.prisma.leg.delete({ where: { id } });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
__decorate([
|
||||||
|
Patch(':id'),
|
||||||
|
__param(0, Param('id', ParseUUIDPipe)),
|
||||||
|
__param(1, Body()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], LegController.prototype, "updateLeg", null);
|
||||||
|
__decorate([
|
||||||
|
Delete(':id'),
|
||||||
|
__param(0, Param('id', ParseUUIDPipe)),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], LegController.prototype, "deleteLeg", null);
|
||||||
|
LegController = __decorate([
|
||||||
|
Controller('v1/legs'),
|
||||||
|
UseGuards(JwtAuthGuard),
|
||||||
|
__metadata("design:paramtypes", [PrismaService])
|
||||||
|
], LegController);
|
||||||
|
function calculateDistance(lat1, lon1, lat2, lon2) {
|
||||||
|
const p = 0.017453292519943295;
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
let RoutingController = class RoutingController {
|
||||||
|
constructor(prisma) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
}
|
||||||
|
async optimize(legId) {
|
||||||
|
const currentLeg = await this.prisma.leg.findUnique({
|
||||||
|
where: { id: legId },
|
||||||
|
});
|
||||||
|
if (!currentLeg)
|
||||||
|
throw new NotFoundException('Không tìm thấy chặng');
|
||||||
|
const locations = await this.prisma.location.findMany({
|
||||||
|
where: { legId },
|
||||||
|
});
|
||||||
|
if (locations.length === 0)
|
||||||
|
return { locations: [], totalDistance: 0 };
|
||||||
|
let startAnchor = null;
|
||||||
|
const prevLeg = await this.prisma.leg.findFirst({
|
||||||
|
where: {
|
||||||
|
tourId: currentLeg.tourId,
|
||||||
|
sequence: currentLeg.sequence - 1
|
||||||
|
},
|
||||||
|
include: { locations: { orderBy: { plannedStart: 'asc' } } }
|
||||||
|
});
|
||||||
|
if (prevLeg?.locations?.length) {
|
||||||
|
startAnchor = prevLeg.locations[prevLeg.locations.length - 1];
|
||||||
|
}
|
||||||
|
if (locations.length <= 2 && !startAnchor)
|
||||||
|
return { locations, totalDistance: 0 };
|
||||||
|
const optimized = [];
|
||||||
|
const unvisited = [...locations];
|
||||||
|
let current;
|
||||||
|
if (startAnchor) {
|
||||||
|
let nearestIdx = 0;
|
||||||
|
let minDist = Infinity;
|
||||||
|
for (let i = 0; i < unvisited.length; i++) {
|
||||||
|
const d = calculateDistance(startAnchor.latitude, startAnchor.longitude, unvisited[i].latitude, unvisited[i].longitude);
|
||||||
|
if (d < minDist) {
|
||||||
|
minDist = d;
|
||||||
|
nearestIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = unvisited.splice(nearestIdx, 1)[0];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
current = unvisited.sort((a, b) => (a.plannedStart?.getTime() || 0) - (b.plannedStart?.getTime() || 0)).shift();
|
||||||
|
}
|
||||||
|
optimized.push(current);
|
||||||
|
while (unvisited.length > 0) {
|
||||||
|
let nearestIdx = 0;
|
||||||
|
let minDist = Infinity;
|
||||||
|
for (let i = 0; i < unvisited.length; i++) {
|
||||||
|
const d = calculateDistance(current.latitude, current.longitude, unvisited[i].latitude, unvisited[i].longitude);
|
||||||
|
if (d < minDist) {
|
||||||
|
minDist = d;
|
||||||
|
nearestIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = unvisited.splice(nearestIdx, 1)[0];
|
||||||
|
optimized.push(current);
|
||||||
|
}
|
||||||
|
let totalDistance = 0;
|
||||||
|
if (startAnchor) {
|
||||||
|
totalDistance += calculateDistance(startAnchor.latitude, startAnchor.longitude, optimized[0].latitude, optimized[0].longitude);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < optimized.length - 1; i++) {
|
||||||
|
totalDistance += calculateDistance(optimized[i].latitude, optimized[i].longitude, optimized[i + 1].latitude, optimized[i + 1].longitude);
|
||||||
|
}
|
||||||
|
const baseTime = optimized[0].plannedStart || new Date();
|
||||||
|
await Promise.all(optimized.map((loc, index) => this.prisma.location.update({
|
||||||
|
where: { id: loc.id },
|
||||||
|
data: { plannedStart: new Date(baseTime.getTime() + index * 3600000) },
|
||||||
|
})));
|
||||||
|
const updatedLocations = await this.prisma.location.findMany({
|
||||||
|
where: { legId },
|
||||||
|
orderBy: { plannedStart: 'asc' }
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
locations: updatedLocations,
|
||||||
|
totalDistance: parseFloat(totalDistance.toFixed(2))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
__decorate([
|
||||||
|
Post('optimize/:legId'),
|
||||||
|
__param(0, Param('legId', ParseUUIDPipe)),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], RoutingController.prototype, "optimize", null);
|
||||||
|
RoutingController = __decorate([
|
||||||
|
Controller('v1/routing'),
|
||||||
|
__metadata("design:paramtypes", [PrismaService])
|
||||||
|
], RoutingController);
|
||||||
|
let UserController = class UserController {
|
||||||
|
constructor(prisma) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
}
|
||||||
|
async getAllUsers(req, q) {
|
||||||
|
const currentUserId = req.user?.sub;
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: q, mode: 'insensitive' } },
|
||||||
|
{ email: { contains: q, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true, createdAt: true, phone: true, address: true }
|
||||||
|
});
|
||||||
|
return users.filter((u) => u.id !== currentUserId);
|
||||||
|
}
|
||||||
|
async updateUser(id, data) {
|
||||||
|
if (data.password) {
|
||||||
|
data.passwordHash = await bcrypt.hash(data.password, 10);
|
||||||
|
delete data.password;
|
||||||
|
}
|
||||||
|
return this.prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async deleteUser(id) {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!user)
|
||||||
|
throw new NotFoundException('Không tìm thấy người dùng');
|
||||||
|
if (user.isAdmin) {
|
||||||
|
const adminCount = await this.prisma.user.count({ where: { isAdmin: true } });
|
||||||
|
if (adminCount <= 1)
|
||||||
|
throw new BadRequestException('Không thể xóa Quản trị viên cuối cùng');
|
||||||
|
}
|
||||||
|
await this.prisma.tourParticipant.deleteMany({ where: { userId: id } });
|
||||||
|
await this.prisma.user.delete({ where: { id } });
|
||||||
|
return { message: 'Đã xóa người dùng' };
|
||||||
|
}
|
||||||
|
async toggleBlock(id) {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!user)
|
||||||
|
throw new NotFoundException('Người dùng không tồn tại');
|
||||||
|
const updated = await this.prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isBlocked: !user.isBlocked },
|
||||||
|
select: { id: true, email: true, name: true, isBlocked: true }
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
__decorate([
|
||||||
|
Get(),
|
||||||
|
__param(0, Req()),
|
||||||
|
__param(1, Query('q')),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [Object, String]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], UserController.prototype, "getAllUsers", null);
|
||||||
|
__decorate([
|
||||||
|
Patch(':id'),
|
||||||
|
__param(0, Param('id', ParseUUIDPipe)),
|
||||||
|
__param(1, Body()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], UserController.prototype, "updateUser", null);
|
||||||
|
__decorate([
|
||||||
|
Delete(':id'),
|
||||||
|
__param(0, Param('id', ParseUUIDPipe)),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], UserController.prototype, "deleteUser", null);
|
||||||
|
__decorate([
|
||||||
|
Post('block/:id'),
|
||||||
|
__param(0, Param('id', ParseUUIDPipe)),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], UserController.prototype, "toggleBlock", null);
|
||||||
|
UserController = __decorate([
|
||||||
|
Controller('v1/users'),
|
||||||
|
__metadata("design:paramtypes", [PrismaService])
|
||||||
|
], UserController);
|
||||||
let AppModule = class AppModule {
|
let AppModule = class AppModule {
|
||||||
};
|
};
|
||||||
AppModule = __decorate([
|
AppModule = __decorate([
|
||||||
(0, common_1.Module)({
|
Module({
|
||||||
controllers: [AppController, TourController],
|
imports: [
|
||||||
providers: [prisma_service_1.PrismaService],
|
JwtModule.register({
|
||||||
exports: [prisma_service_1.PrismaService]
|
secret: process.env.JWT_SECRET || 'super-secret',
|
||||||
|
signOptions: { expiresIn: '1d' },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AppController, AuthController, TourController, UserController, RoutingController, LegController, LocationController],
|
||||||
|
providers: [PrismaService, JwtStrategy, TourRoleGuard],
|
||||||
|
exports: [PrismaService]
|
||||||
})
|
})
|
||||||
], AppModule);
|
], AppModule);
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await core_1.NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
app.enableCors();
|
app.enableCors();
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix('api');
|
||||||
const port = process.env.PORT || 3001;
|
const port = process.env.PORT || 3001;
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+4
-5
@@ -1,10 +1,9 @@
|
|||||||
"use strict";
|
import { defineConfig } from '@prisma/config';
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
import 'dotenv/config';
|
||||||
const config_1 = require("@prisma/config");
|
export default defineConfig({
|
||||||
require("dotenv/config");
|
|
||||||
exports.default = (0, config_1.defineConfig)({
|
|
||||||
datasource: {
|
datasource: {
|
||||||
url: process.env.DATABASE_URL,
|
url: process.env.DATABASE_URL,
|
||||||
},
|
},
|
||||||
|
schema: 'prisma/schema.prisma',
|
||||||
});
|
});
|
||||||
//# sourceMappingURL=prisma.config.js.map
|
//# sourceMappingURL=prisma.config.js.map
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"prisma.config.js","sourceRoot":"","sources":["../prisma.config.ts"],"names":[],"mappings":";;AAAA,2CAA8C;AAC9C,yBAAuB;AAEvB,kBAAe,IAAA,qBAAY,EAAC;IAC1B,UAAU,EAAE;QAEV,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY;KAC9B;CACF,CAAC,CAAC"}
|
{"version":3,"file":"prisma.config.js","sourceRoot":"","sources":["../prisma.config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,eAAe,CAAC;AAEvB,eAAe,YAAY,CAAC;IAC1B,UAAU,EAAE;QAEV,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY;KAC9B;IACD,MAAM,EAAE,sBAAsB;CAC/B,CAAC,CAAC"}
|
||||||
Vendored
-2
@@ -1,8 +1,6 @@
|
|||||||
import { OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
import { OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import 'dotenv/config';
|
|
||||||
export declare class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
export declare class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
private pool;
|
|
||||||
constructor();
|
constructor();
|
||||||
onModuleInit(): Promise<void>;
|
onModuleInit(): Promise<void>;
|
||||||
onModuleDestroy(): Promise<void>;
|
onModuleDestroy(): Promise<void>;
|
||||||
|
|||||||
Vendored
+11
-19
@@ -1,4 +1,3 @@
|
|||||||
"use strict";
|
|
||||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||||
@@ -8,29 +7,22 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|||||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||||
};
|
};
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
import { Injectable } from '@nestjs/common';
|
||||||
exports.PrismaService = void 0;
|
import { PrismaClient } from '@prisma/client';
|
||||||
const common_1 = require("@nestjs/common");
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
const client_1 = require("@prisma/client");
|
import { Pool } from 'pg';
|
||||||
const adapter_pg_1 = require("@prisma/adapter-pg");
|
let PrismaService = class PrismaService extends PrismaClient {
|
||||||
const pg_1 = require("pg");
|
|
||||||
require("dotenv/config");
|
|
||||||
let PrismaService = class PrismaService extends client_1.PrismaClient {
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const pool = new pg_1.Pool({ connectionString: process.env.DATABASE_URL });
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
const adapter = new adapter_pg_1.PrismaPg(pool);
|
const adapter = new PrismaPg(pool);
|
||||||
super({ adapter });
|
super({ adapter });
|
||||||
this.pool = pool;
|
|
||||||
}
|
}
|
||||||
async onModuleInit() { await this.$connect(); }
|
async onModuleInit() { await this.$connect(); }
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() { await this.$disconnect(); }
|
||||||
await this.$disconnect();
|
|
||||||
await this.pool.end();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
exports.PrismaService = PrismaService;
|
PrismaService = __decorate([
|
||||||
exports.PrismaService = PrismaService = __decorate([
|
Injectable(),
|
||||||
(0, common_1.Injectable)(),
|
|
||||||
__metadata("design:paramtypes", [])
|
__metadata("design:paramtypes", [])
|
||||||
], PrismaService);
|
], PrismaService);
|
||||||
|
export { PrismaService };
|
||||||
//# sourceMappingURL=prisma.service.js.map
|
//# sourceMappingURL=prisma.service.js.map
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"prisma.service.js","sourceRoot":"","sources":["../prisma.service.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA2E;AAC3E,2CAA8C;AAC9C,mDAA8C;AAC9C,2BAA0B;AAC1B,yBAAuB;AAGhB,IAAM,aAAa,GAAnB,MAAM,aAAc,SAAQ,qBAAY;IAG7C;QACE,MAAM,IAAI,GAAG,IAAI,SAAI,CAAC,EAAE,gBAAgB,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC,CAAC;QACtE,MAAM,OAAO,GAAG,IAAI,qBAAQ,CAAC,IAAI,CAAC,CAAC;QACnC,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC;QACnB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;IAED,KAAK,CAAC,YAAY,KAAK,MAAM,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC;IAC/C,KAAK,CAAC,eAAe;QACnB,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC;QACzB,MAAM,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC;IACxB,CAAC;CACF,CAAA;AAfY,sCAAa;wBAAb,aAAa;IADzB,IAAA,mBAAU,GAAE;;GACA,aAAa,CAezB"}
|
{"version":3,"file":"prisma.service.js","sourceRoot":"","sources":["../prisma.service.ts"],"names":[],"mappings":";;;;;;;;;AAAA,OAAO,EAAE,UAAU,EAAiC,MAAM,gBAAgB,CAAC;AAC3E,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC9C,OAAO,EAAE,IAAI,EAAE,MAAM,IAAI,CAAC;AAGnB,IAAM,aAAa,GAAnB,MAAM,aAAc,SAAQ,YAAY;IAC7C;QACE,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,EAAE,gBAAgB,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC,CAAC;QACtE,MAAM,OAAO,GAAG,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAC;QACnC,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC;IACrB,CAAC;IAED,KAAK,CAAC,YAAY,KAAK,MAAM,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC;IAC/C,KAAK,CAAC,eAAe,KAAK,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;CACtD,CAAA;AATY,aAAa;IADzB,UAAU,EAAE;;GACA,aAAa,CASzB"}
|
||||||
Vendored
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { CanActivate, ExecutionContext } from '@nestjs/common';
|
import { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||||
import { PrismaService } from './prisma.service';
|
import { PrismaService } from './prisma.service.js';
|
||||||
export declare class TourRoleGuard implements CanActivate {
|
export declare class TourRoleGuard implements CanActivate {
|
||||||
private prisma;
|
private prisma;
|
||||||
constructor(prisma: PrismaService);
|
constructor(prisma: PrismaService);
|
||||||
|
|||||||
Vendored
+20
-19
@@ -1,4 +1,3 @@
|
|||||||
"use strict";
|
|
||||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||||
@@ -8,10 +7,8 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|||||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||||
};
|
};
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
import { Injectable, ForbiddenException } from '@nestjs/common';
|
||||||
exports.TourRoleGuard = void 0;
|
import { PrismaService } from './prisma.service.js';
|
||||||
const common_1 = require("@nestjs/common");
|
|
||||||
const prisma_service_1 = require("./prisma.service");
|
|
||||||
let TourRoleGuard = class TourRoleGuard {
|
let TourRoleGuard = class TourRoleGuard {
|
||||||
constructor(prisma) {
|
constructor(prisma) {
|
||||||
this.prisma = prisma;
|
this.prisma = prisma;
|
||||||
@@ -19,12 +16,16 @@ let TourRoleGuard = class TourRoleGuard {
|
|||||||
async canActivate(context) {
|
async canActivate(context) {
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const user = request.user;
|
const user = request.user;
|
||||||
const tourId = parseInt(request.params.id || request.params.tourId);
|
const tourId = request.params.id || request.params.tourId;
|
||||||
const path = request.url;
|
const path = request.url;
|
||||||
if (!user || isNaN(tourId)) {
|
if (!user || !tourId) {
|
||||||
throw new common_1.ForbiddenException("Thông tin xác thực hoặc mã Tour không hợp lệ.");
|
throw new ForbiddenException("Thông tin xác thực hoặc mã Tour không hợp lệ.");
|
||||||
}
|
}
|
||||||
const membership = await this.prisma.tourMember.findUnique({
|
if (path.includes('/join-requests') && request.method === 'POST' && (!request.params.requestId || /\/join-requests\/[^/]+\/(accept|reject)$/.test(path))) {
|
||||||
|
request.tourParticipation = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const participation = await this.prisma.tourParticipant.findUnique({
|
||||||
where: {
|
where: {
|
||||||
tourId_userId: {
|
tourId_userId: {
|
||||||
tourId: tourId,
|
tourId: tourId,
|
||||||
@@ -32,23 +33,23 @@ let TourRoleGuard = class TourRoleGuard {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!membership) {
|
if (!participation) {
|
||||||
throw new common_1.ForbiddenException("Bạn không phải là thành viên của tour này.");
|
throw new ForbiddenException("Bạn không phải là thành viên của tour này.");
|
||||||
}
|
}
|
||||||
request.tourMembership = membership;
|
request.tourParticipation = participation;
|
||||||
const role = membership.role;
|
const role = participation.role;
|
||||||
const isPlanPath = path.includes('/plans');
|
const isPlanPath = path.includes('/plans');
|
||||||
const isExpensePath = path.includes('/expenses');
|
const isExpensePath = path.includes('/expenses');
|
||||||
if ((role === 'MEMBER_PHOTO_ONLY' || role === 'VIEWER_EXTERNAL') &&
|
if ((role === 'MEMBER_NO_FINANCE' || role === 'VIEWER_ONLY') &&
|
||||||
(isPlanPath || isExpensePath)) {
|
(isPlanPath || isExpensePath)) {
|
||||||
throw new common_1.ForbiddenException("Bạn không có quyền xem kế hoạch và chi phí của tour này.");
|
throw new ForbiddenException("Bạn không có quyền xem kế hoạch và chi phí của tour này.");
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
exports.TourRoleGuard = TourRoleGuard;
|
TourRoleGuard = __decorate([
|
||||||
exports.TourRoleGuard = TourRoleGuard = __decorate([
|
Injectable(),
|
||||||
(0, common_1.Injectable)(),
|
__metadata("design:paramtypes", [PrismaService])
|
||||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
|
||||||
], TourRoleGuard);
|
], TourRoleGuard);
|
||||||
|
export { TourRoleGuard };
|
||||||
//# sourceMappingURL=rbac.middleware.js.map
|
//# sourceMappingURL=rbac.middleware.js.map
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"rbac.middleware.js","sourceRoot":"","sources":["../rbac.middleware.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA+F;AAC/F,qDAAiD;AAG1C,IAAM,aAAa,GAAnB,MAAM,aAAa;IACxB,YAAoB,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAE7C,KAAK,CAAC,WAAW,CAAC,OAAyB;QACzC,MAAM,OAAO,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,UAAU,EAAE,CAAC;QACpD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;QAG1B,MAAM,MAAM,GAAG,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QACpE,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC;QAEzB,IAAI,CAAC,IAAI,IAAI,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC;YAC3B,MAAM,IAAI,2BAAkB,CAAC,+CAA+C,CAAC,CAAC;QAChF,CAAC;QAOD,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,UAAU,CAAC;YACzD,KAAK,EAAE;gBACL,aAAa,EAAE;oBACb,MAAM,EAAE,MAAM;oBACd,MAAM,EAAE,IAAI,CAAC,EAAE;iBAChB;aACF;SACF,CAAC,CAAC;QAEH,IAAI,CAAC,UAAU,EAAE,CAAC;YAChB,MAAM,IAAI,2BAAkB,CAAC,4CAA4C,CAAC,CAAC;QAC7E,CAAC;QAGD,OAAO,CAAC,cAAc,GAAG,UAAU,CAAC;QAEpC,MAAM,IAAI,GAAG,UAAU,CAAC,IAAI,CAAC;QAC7B,MAAM,UAAU,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;QAC3C,MAAM,aAAa,GAAG,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;QAGjD,IACE,CAAC,IAAI,KAAK,mBAAmB,IAAI,IAAI,KAAK,iBAAiB,CAAC;YAC5D,CAAC,UAAU,IAAI,aAAa,CAAC,EAC7B,CAAC;YACD,MAAM,IAAI,2BAAkB,CAAC,0DAA0D,CAAC,CAAC;QAC3F,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAlDY,sCAAa;wBAAb,aAAa;IADzB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,aAAa,CAkDzB"}
|
{"version":3,"file":"rbac.middleware.js","sourceRoot":"","sources":["../rbac.middleware.ts"],"names":[],"mappings":";;;;;;;;;AAAA,OAAO,EAAE,UAAU,EAAiC,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAC/F,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAG7C,IAAM,aAAa,GAAnB,MAAM,aAAa;IACxB,YAAoB,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAE7C,KAAK,CAAC,WAAW,CAAC,OAAyB;QACzC,MAAM,OAAO,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,UAAU,EAAE,CAAC;QACpD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;QAE1B,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,EAAE,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC;QAC1D,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC;QAEzB,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YACrB,MAAM,IAAI,kBAAkB,CAAC,+CAA+C,CAAC,CAAC;QAChF,CAAC;QAED,IAAI,IAAI,CAAC,QAAQ,CAAC,gBAAgB,CAAC,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM,IAAI,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,SAAS,IAAI,0CAA0C,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC;YACzJ,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;YACjC,OAAO,IAAI,CAAC;QACd,CAAC;QAED,MAAM,aAAa,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,eAAe,CAAC,UAAU,CAAC;YACjE,KAAK,EAAE;gBACL,aAAa,EAAE;oBACb,MAAM,EAAE,MAAM;oBACd,MAAM,EAAE,IAAI,CAAC,EAAE;iBAChB;aACF;SACF,CAAC,CAAC;QAEH,IAAI,CAAC,aAAa,EAAE,CAAC;YACnB,MAAM,IAAI,kBAAkB,CAAC,4CAA4C,CAAC,CAAC;QAC7E,CAAC;QAED,OAAO,CAAC,iBAAiB,GAAG,aAAa,CAAC;QAE1C,MAAM,IAAI,GAAG,aAAa,CAAC,IAAI,CAAC;QAChC,MAAM,UAAU,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;QAC3C,MAAM,aAAa,GAAG,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;QAEjD,IACE,CAAC,IAAI,KAAK,mBAAmB,IAAI,IAAI,KAAK,aAAa,CAAC;YACxD,CAAC,UAAU,IAAI,aAAa,CAAC,EAC7B,CAAC;YACD,MAAM,IAAI,kBAAkB,CAAC,0DAA0D,CAAC,CAAC;QAC3F,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AA/CY,aAAa;IADzB,UAAU,EAAE;qCAEiB,aAAa;GAD9B,aAAa,CA+CzB"}
|
||||||
Vendored
+23
-24
@@ -1,12 +1,11 @@
|
|||||||
"use strict";
|
import { PrismaClient } from '@prisma/client';
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
const client_1 = require("@prisma/client");
|
import { Pool } from 'pg';
|
||||||
const adapter_pg_1 = require("@prisma/adapter-pg");
|
import 'dotenv/config';
|
||||||
const pg_1 = require("pg");
|
import * as bcrypt from 'bcrypt';
|
||||||
require("dotenv/config");
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
const pool = new pg_1.Pool({ connectionString: process.env.DATABASE_URL });
|
const adapter = new PrismaPg(pool);
|
||||||
const adapter = new adapter_pg_1.PrismaPg(pool);
|
const prisma = new PrismaClient({ adapter });
|
||||||
const prisma = new client_1.PrismaClient({ adapter });
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('--- Đang xóa dữ liệu cũ... ---');
|
console.log('--- Đang xóa dữ liệu cũ... ---');
|
||||||
await prisma.expense.deleteMany();
|
await prisma.expense.deleteMany();
|
||||||
@@ -18,14 +17,15 @@ async function main() {
|
|||||||
data: {
|
data: {
|
||||||
email: 'owner@travel.com',
|
email: 'owner@travel.com',
|
||||||
name: 'Lộc Phạm (Chủ Tour)',
|
name: 'Lộc Phạm (Chủ Tour)',
|
||||||
passwordHash: 'hashed_password_123',
|
passwordHash: await bcrypt.hash('123456', 10),
|
||||||
|
isAdmin: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const photoMember = await prisma.user.create({
|
const photoMember = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: 'photomember@travel.com',
|
email: 'photomember@travel.com',
|
||||||
name: 'Nguyễn Văn Ảnh (Chỉ xem ảnh)',
|
name: 'Nguyễn Văn Ảnh (Chỉ xem ảnh)',
|
||||||
passwordHash: 'hashed_password_456',
|
passwordHash: await bcrypt.hash('123456', 10),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log('--- Đang tạo Tour và phân quyền... ---');
|
console.log('--- Đang tạo Tour và phân quyền... ---');
|
||||||
@@ -34,11 +34,11 @@ async function main() {
|
|||||||
title: 'Hành trình khám phá TP.HCM',
|
title: 'Hành trình khám phá TP.HCM',
|
||||||
startDate: new Date('2023-11-20'),
|
startDate: new Date('2023-11-20'),
|
||||||
endDate: new Date('2023-11-21'),
|
endDate: new Date('2023-11-21'),
|
||||||
creatorId: owner.id,
|
createdById: owner.id,
|
||||||
members: {
|
participants: {
|
||||||
create: [
|
create: [
|
||||||
{ userId: owner.id, role: 'OWNER' },
|
{ userId: owner.id, role: 'OWNER' },
|
||||||
{ userId: photoMember.id, role: 'MEMBER_PHOTO_ONLY' },
|
{ userId: photoMember.id, role: 'VIEWER_ONLY' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -47,38 +47,37 @@ async function main() {
|
|||||||
const leg1 = await prisma.leg.create({
|
const leg1 = await prisma.leg.create({
|
||||||
data: {
|
data: {
|
||||||
tourId: tour.id,
|
tourId: tour.id,
|
||||||
sequenceNumber: 1,
|
sequence: 1,
|
||||||
notes: 'Khám phá lịch sử trung tâm',
|
note: 'Khám phá lịch sử trung tâm',
|
||||||
places: {
|
locations: {
|
||||||
create: [
|
create: [
|
||||||
{
|
{
|
||||||
name: 'Dinh Độc Lập',
|
name: 'Dinh Độc Lập',
|
||||||
address: '135 Nam Kỳ Khởi Nghĩa, Quận 1',
|
address: '135 Nam Kỳ Khởi Nghĩa, Quận 1',
|
||||||
latitude: 10.777,
|
latitude: 10.777,
|
||||||
longitude: 106.695,
|
longitude: 106.695,
|
||||||
sequenceInLeg: 1,
|
plannedStart: new Date('2023-11-20T08:00:00Z'),
|
||||||
arrivalTime: new Date('2023-11-20T08:00:00Z'),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Bưu điện Thành phố',
|
name: 'Bưu điện Thành phố',
|
||||||
address: '02 Công xã Paris, Quận 1',
|
address: '02 Công xã Paris, Quận 1',
|
||||||
latitude: 10.779,
|
latitude: 10.779,
|
||||||
longitude: 106.699,
|
longitude: 106.699,
|
||||||
sequenceInLeg: 2,
|
plannedStart: new Date('2023-11-20T10:00:00Z'),
|
||||||
arrivalTime: new Date('2023-11-20T10:00:00Z'),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log('--- Đang tạo chi phí mẫu... ---');
|
console.log('--- Đang tạo chi phí mẫu... ---');
|
||||||
await prisma.expense.create({
|
const expense1 = await prisma.expense.create({
|
||||||
data: {
|
data: {
|
||||||
legId: leg1.id,
|
legId: leg1.id,
|
||||||
category: 'DINING',
|
category: 'FOOD',
|
||||||
amount: 500000,
|
amount: 500000,
|
||||||
currency: 'VND',
|
|
||||||
description: 'Ăn trưa đặc sản Quận 1',
|
description: 'Ăn trưa đặc sản Quận 1',
|
||||||
|
note: 'Đặt trước cho 3 người',
|
||||||
|
paidById: owner.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log('--- Seed dữ liệu hoàn tất! ---');
|
console.log('--- Seed dữ liệu hoàn tất! ---');
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"seed.js","sourceRoot":"","sources":["../seed.ts"],"names":[],"mappings":"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"}
|
{"version":3,"file":"seed.js","sourceRoot":"","sources":["../seed.ts"],"names":[],"mappings":"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"}
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
declare const _default: {
|
||||||
|
content: string[];
|
||||||
|
theme: {
|
||||||
|
extend: {};
|
||||||
|
};
|
||||||
|
plugins: any[];
|
||||||
|
};
|
||||||
|
export default _default;
|
||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./*.{js,ts,jsx,tsx}",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
//# sourceMappingURL=tailwind.config.js.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"tailwind.config.js","sourceRoot":"","sources":["../tailwind.config.ts"],"names":[],"mappings":"AAEA,eAAe;IACb,OAAO,EAAE;QACP,cAAc;QACd,qBAAqB;QACrB,4BAA4B;KAC7B;IACD,KAAK,EAAE;QACL,MAAM,EAAE,EAAE;KACX;IACD,OAAO,EAAE,EAAE;CACK,CAAA"}
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+29
-2
@@ -1,11 +1,38 @@
|
|||||||
interface TourState {
|
interface TourState {
|
||||||
currentTour: any;
|
currentTour: any;
|
||||||
legs: any[];
|
legs: any[];
|
||||||
|
publicTours: any[];
|
||||||
userRole: string | null;
|
userRole: string | null;
|
||||||
|
activeLegId: string | null;
|
||||||
|
mapCenter: [number, number];
|
||||||
setTour: (tour: any) => void;
|
setTour: (tour: any) => void;
|
||||||
updateLegs: (legs: any[]) => void;
|
updateLegs: (legs: any[]) => void;
|
||||||
optimizeRouting: () => Promise<void>;
|
createTour: (tourData: any) => Promise<any>;
|
||||||
fetchTour: (id: number) => Promise<void>;
|
updateTour: (id: string, data: any) => Promise<void>;
|
||||||
|
deleteTour: (id: string) => Promise<void>;
|
||||||
|
addLeg: (tourId: string, data: any) => Promise<void>;
|
||||||
|
initializeLegs: (tourId: string, count: number) => Promise<void>;
|
||||||
|
updateLeg: (legId: string, data: any) => Promise<void>;
|
||||||
|
deleteLeg: (legId: string) => Promise<void>;
|
||||||
|
addLocation: (tourId: string, locationData: any) => Promise<void>;
|
||||||
|
updateLocation: (locationId: string, data: any) => Promise<void>;
|
||||||
|
deleteLocation: (locationId: string) => Promise<void>;
|
||||||
|
updateTourStartPoint: (tourId: string, data: any) => Promise<void>;
|
||||||
|
updateTourEndPoint: (tourId: string, data: any) => Promise<void>;
|
||||||
|
optimizeRouting: (legId: string) => Promise<void>;
|
||||||
|
addMember: (tourId: string, member: {
|
||||||
|
userId: string;
|
||||||
|
role?: string;
|
||||||
|
}) => Promise<any>;
|
||||||
|
removeMember: (tourId: string, userId: string) => Promise<void>;
|
||||||
|
setActiveLegId: (id: string | null) => void;
|
||||||
|
setMapCenter: (pos: [number, number]) => void;
|
||||||
|
fetchTour: (id: string) => Promise<void>;
|
||||||
|
fetchPublicTours: () => Promise<void>;
|
||||||
|
createJoinRequest: (tourId: string, userId?: string) => Promise<any>;
|
||||||
|
fetchJoinRequests: (tourId: string) => Promise<any[]>;
|
||||||
|
acceptJoinRequest: (tourId: string, requestId: string) => Promise<any>;
|
||||||
|
rejectJoinRequest: (tourId: string, requestId: string) => Promise<any>;
|
||||||
}
|
}
|
||||||
export declare const useTourStore: import("zustand").UseBoundStore<import("zustand").StoreApi<TourState>>;
|
export declare const useTourStore: import("zustand").UseBoundStore<import("zustand").StoreApi<TourState>>;
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
Vendored
+334
-10
@@ -1,21 +1,345 @@
|
|||||||
"use strict";
|
import { create } from 'zustand';
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
export const useTourStore = create((set, get) => ({
|
||||||
exports.useTourStore = void 0;
|
|
||||||
const zustand_1 = require("zustand");
|
|
||||||
exports.useTourStore = (0, zustand_1.create)((set, get) => ({
|
|
||||||
currentTour: null,
|
currentTour: null,
|
||||||
legs: [],
|
legs: [],
|
||||||
|
publicTours: [],
|
||||||
userRole: null,
|
userRole: null,
|
||||||
|
activeLegId: null,
|
||||||
|
mapCenter: [10.7769, 106.7009],
|
||||||
setTour: (tour) => set({ currentTour: tour }),
|
setTour: (tour) => set({ currentTour: tour }),
|
||||||
updateLegs: (legs) => set({ legs }),
|
updateLegs: (legs) => set({ legs }),
|
||||||
|
setActiveLegId: (id) => set({ activeLegId: id }),
|
||||||
|
setMapCenter: (pos) => set({ mapCenter: pos }),
|
||||||
fetchTour: async (id) => {
|
fetchTour: async (id) => {
|
||||||
const currentUserId = 1;
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
const response = await fetch(`http://localhost:3001/api/v1/tours/${id}`);
|
const token = localStorage.getItem('token');
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const role = data.members?.find((m) => m.userId === currentUserId)?.role || 'VIEWER_EXTERNAL';
|
let role = 'VIEWER_ONLY';
|
||||||
set({ currentTour: data, legs: data.legs || [], userRole: role });
|
if (token) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
const currentUserId = payload.sub;
|
||||||
|
const participant = data.participants?.find((p) => p.userId === currentUserId);
|
||||||
|
if (participant)
|
||||||
|
role = participant.role;
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error("Lỗi khi xác định vai trò người dùng:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const legs = data.legs || [];
|
||||||
|
set({ currentTour: data, legs, userRole: role, activeLegId: legs.length > 0 ? legs[0].id : null });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('Không thể tải tour:', err);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
optimizeRouting: async () => {
|
fetchPublicTours: async () => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token)
|
||||||
|
return;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/explore`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
return;
|
||||||
|
const data = await response.json();
|
||||||
|
set({ publicTours: data });
|
||||||
|
},
|
||||||
|
createTour: async (tourData) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(tourData),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.message || data.error || 'Lỗi khi tạo Tour');
|
||||||
|
}
|
||||||
|
const tour = await response.json();
|
||||||
|
await get().fetchPublicTours();
|
||||||
|
return tour;
|
||||||
|
},
|
||||||
|
updateTour: async (id, data) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Lỗi khi cập nhật Tour');
|
||||||
|
get().fetchPublicTours();
|
||||||
|
},
|
||||||
|
deleteTour: async (id) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.message || 'Lỗi khi xóa Tour');
|
||||||
|
}
|
||||||
|
if (get().currentTour?.id === id)
|
||||||
|
set({ currentTour: null });
|
||||||
|
get().fetchPublicTours();
|
||||||
|
},
|
||||||
|
addLeg: async (tourId, data) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/legs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Lỗi khi thêm chặng');
|
||||||
|
get().fetchTour(tourId);
|
||||||
|
},
|
||||||
|
initializeLegs: async (tourId, count) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/legs/batch`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ count }),
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Lỗi khi khởi tạo chặng');
|
||||||
|
await get().fetchTour(tourId);
|
||||||
|
},
|
||||||
|
updateLeg: async (legId, data) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/legs/${legId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Lỗi khi cập nhật chặng');
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour)
|
||||||
|
get().fetchTour(currentTour.id);
|
||||||
|
},
|
||||||
|
deleteLeg: async (legId) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/legs/${legId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.message || 'Lỗi khi xóa chặng');
|
||||||
|
}
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour)
|
||||||
|
get().fetchTour(currentTour.id);
|
||||||
|
},
|
||||||
|
addLocation: async (tourId, locationData) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/locations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(locationData),
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Lỗi khi thêm địa điểm');
|
||||||
|
get().fetchTour(tourId);
|
||||||
|
},
|
||||||
|
updateLocation: async (locationId, data) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/locations/${locationId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Lỗi khi cập nhật địa điểm');
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour)
|
||||||
|
get().fetchTour(currentTour.id);
|
||||||
|
},
|
||||||
|
deleteLocation: async (locationId) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/locations/${locationId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Lỗi khi xóa địa điểm');
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour)
|
||||||
|
get().fetchTour(currentTour.id);
|
||||||
|
},
|
||||||
|
updateTourStartPoint: async (tourId, data) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/start-point`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
console.log(`[STORE] updateTourStartPoint API Status: ${response.status}`);
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Lỗi khi thiết lập điểm bắt đầu');
|
||||||
|
await get().fetchTour(tourId);
|
||||||
|
},
|
||||||
|
updateTourEndPoint: async (tourId, data) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/end-point`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Lỗi khi thiết lập điểm kết thúc');
|
||||||
|
await get().fetchTour(tourId);
|
||||||
|
},
|
||||||
|
optimizeRouting: async (legId) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/routing/optimize/${legId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const { locations, totalDistance } = await response.json();
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour) {
|
||||||
|
const updatedLegs = currentTour.legs.map((l) => l.id === legId ? { ...l, locations: locations, totalDistance: totalDistance } : l);
|
||||||
|
set({ currentTour: { ...currentTour, legs: updatedLegs }, legs: updatedLegs });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeMember: async (tourId, userId) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Lỗi khi xóa thành viên');
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour)
|
||||||
|
get().fetchTour(currentTour.id);
|
||||||
|
},
|
||||||
|
addMember: async (tourId, member) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(member),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.message || data.error || 'Lỗi khi thêm thành viên');
|
||||||
|
}
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour)
|
||||||
|
get().fetchTour(currentTour.id);
|
||||||
|
},
|
||||||
|
createJoinRequest: async (tourId, userId) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ userId }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.message || data.error || 'Lỗi khi tạo yêu cầu tham gia');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
fetchJoinRequests: async (tourId) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.message || data.error || 'Lỗi khi tải yêu cầu tham gia');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
acceptJoinRequest: async (tourId, requestId) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests/${requestId}/accept`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.message || data.error || 'Lỗi khi chấp nhận yêu cầu');
|
||||||
|
}
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour)
|
||||||
|
get().fetchTour(currentTour.id);
|
||||||
|
},
|
||||||
|
rejectJoinRequest: async (tourId, requestId) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests/${requestId}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.message || data.error || 'Lỗi khi từ chối yêu cầu');
|
||||||
|
}
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour)
|
||||||
|
get().fetchTour(currentTour.id);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
//# sourceMappingURL=useTourStore.js.map
|
//# sourceMappingURL=useTourStore.js.map
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
declare const _default: import("vite").UserConfig;
|
||||||
|
export default _default;
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3002,
|
||||||
|
strictPort: true,
|
||||||
|
host: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
//# sourceMappingURL=vite.config.js.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"vite.config.js","sourceRoot":"","sources":["../vite.config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,MAAM,CAAC;AACpC,OAAO,KAAK,MAAM,sBAAsB,CAAC;AAEzC,eAAe,YAAY,CAAC;IAC1B,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC;IAClB,MAAM,EAAE;QACN,IAAI,EAAE,IAAI;QACV,UAAU,EAAE,IAAI;QAChB,IAAI,EAAE,IAAI;KACX;CACF,CAAC,CAAC"}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# docs/help/
|
||||||
|
Help snippet index for this project.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html, body, #root, .app-container {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.banner-location-text {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 80px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: bottom;
|
||||||
|
cursor: help; /* Hiển thị biểu tượng giúp đỡ để gợi ý có tooltip */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.banner-location-text {
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user