Compare commits
27 Commits
| 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 |
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
### 3.1. Users & Authentication
|
||||
* `users`: Lưu thông tin định danh (`id`, `email`, `password_hash`, `name`, `avatar`).
|
||||
### 3.1. User (Người dùng)
|
||||
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)
|
||||
* `legs` (Chặng): Một tour có nhiều chặng.
|
||||
* `id` (PK), `tour_id` (FK), `sequence_number` (Thứ tự chặng: 1, 2, 3...), `notes`.
|
||||
* `places` (Địa điểm trong chặng):
|
||||
* `id` (PK), `leg_id` (FK), `name`, `address`, `latitude`, `longitude` (Dữ liệu PostGIS), `sequence_in_leg`.
|
||||
* `arrival_time` (Dự kiến), `departure_time` (Dự kiến).
|
||||
### 3.2. Tour & Phân quyền (RBAC)
|
||||
Hệ thống sử dụng bảng trung gian để quản lý thành viên cho từng chuyến đi.
|
||||
* `Tour`: `id`, `title`, `startDate`, `endDate`, `totalCost` (Decimal), `createdById` (FK -> User).
|
||||
* `TourParticipant`: Quản lý vai trò thành viên trong Tour qua enum `ParticipantRole`:
|
||||
* `OWNER`: Toàn quyền quản lý.
|
||||
* `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í)
|
||||
* `expenses`: Lưu vết chi phí cho từng địa điểm/chặng.
|
||||
* `id` (PK), `leg_id` (FK), `place_id` (FK, nullable), `category` (`LODGING`, `DINING`, `TRANSPORT`, `OTHER`), `amount` (Số tiền), `currency`, `description`.
|
||||
* `tour_cost_summaries`: Cấu hình phân chia tiền ở cuối tour/chặng.
|
||||
* `tour_id` (FK), `total_cost`, `adult_count`, `child_count`, `child_discount_percent` (Ví dụ: trẻ em giảm 30%).
|
||||
### 3.3. Lộ trình (Leg & Location)
|
||||
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).
|
||||
* `Leg`: `id`, `tourId` (FK), `sequence` (Thứ tự chặng), `note`.
|
||||
* `Location`: Tích hợp tính năng theo dõi tiến độ (Thay thế khái niệm `Task` cũ).
|
||||
* 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 độ)
|
||||
* `tasks`: Các hoạt động cần tích chọn hoàn thành.
|
||||
* `id` (PK), `tour_id` (FK), `leg_id` (FK), `title`, `planned_timestamp`.
|
||||
* `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.4. Chi phí (Expense)
|
||||
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ể.
|
||||
* `Expense`: `id` (UUID, PK), `leg_id` (UUID, FK), `location_id` (UUID, FK, Nullable), `category` (Enum: `ACCOMMODATION`, `FOOD`, `TRANSPORT`, `TICKET`, `OTHER`), `amount` (Decimal), `description` (Text).
|
||||
|
||||
### 3.6. Album Ảnh (Tách biệt logic kế hoạch)
|
||||
* `photos`: Lưu trữ hình ảnh của tour gắn với địa điểm.
|
||||
* `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`).
|
||||
### 3.5. Album Ảnh (Photo)
|
||||
Lưu trữ tài nguyên đa phương tiện gắn với bối cảnh chuyến đi.
|
||||
* `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:
|
||||
* 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`).
|
||||
|
||||
# 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>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { LandingPage } from './LandingPage.js';
|
||||
import { TourDetailPage } from './TourDetailPage.js';
|
||||
import { ExploreMap } from './ExploreMap.js';
|
||||
import { SignupPage } from './SignupPage.js';
|
||||
import { useTourStore } from './useTourStore.js';
|
||||
|
||||
const App = () => {
|
||||
type View = 'landing' | 'explore' | 'detail' | 'signup';
|
||||
@@ -10,6 +11,7 @@ const App = () => {
|
||||
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
|
||||
@@ -72,11 +74,15 @@ const App = () => {
|
||||
onBack={() => setView('landing')}
|
||||
onLogout={user ? handleLogout : undefined}
|
||||
user={user}
|
||||
onViewTour={(id) => {
|
||||
fetchTour(id);
|
||||
setView('detail');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'detail' && (
|
||||
<TourDetailPage />
|
||||
<TourDetailPage onBack={() => setView('explore')} />
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
+97
-25
@@ -1,10 +1,13 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap } 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 'leaflet/dist/leaflet.css';
|
||||
import { useTourStore } from './useTourStore.js';
|
||||
import { X, Navigation, Image as ImageIcon, LogOut, Settings } 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
|
||||
const DefaultIcon = L.icon({
|
||||
@@ -24,17 +27,59 @@ function RecenterMap({ position }: { position: [number, number] }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ExploreMap = ({ onBack, onLogout, user }: { onBack: () => void, onLogout?: () => void, user?: any }) => {
|
||||
const { publicTours, fetchPublicTours } = useTourStore();
|
||||
const [userPos, setUserPos] = useState<[number, number]>([10.7769, 106.7009]); // Mặc định TP.HCM
|
||||
// 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(() => {
|
||||
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(
|
||||
(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")
|
||||
);
|
||||
} else {
|
||||
// Cập nhật store để đồng bộ với vị trí khởi tạo từ cache
|
||||
setMapCenter(initialViewState.center);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -69,6 +114,17 @@ export const ExploreMap = ({ onBack, onLogout, user }: { onBack: () => void, onL
|
||||
</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 */}
|
||||
<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">
|
||||
@@ -77,25 +133,38 @@ export const ExploreMap = ({ onBack, onLogout, user }: { onBack: () => void, onL
|
||||
</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
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution='© OpenStreetMap contributors'
|
||||
/>
|
||||
|
||||
{/* Theo dõi di chuyển bản đồ */}
|
||||
<MapTracker />
|
||||
|
||||
{/* 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 place = tour.legs?.[0]?.places?.[0];
|
||||
if (!place) return null;
|
||||
|
||||
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] as [number, number]
|
||||
: userPos;
|
||||
|
||||
return (
|
||||
<React.Fragment key={tour.id}>
|
||||
<Marker
|
||||
key={tour.id}
|
||||
position={[place.latitude, place.longitude]}
|
||||
position={markerPos}
|
||||
eventHandlers={{
|
||||
click: () => onViewTour(tour.id)
|
||||
}}
|
||||
icon={L.divIcon({
|
||||
className: 'custom-bubble',
|
||||
html: `
|
||||
@@ -103,30 +172,33 @@ export const ExploreMap = ({ onBack, onLogout, user }: { onBack: () => void, onL
|
||||
<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 p-1 border-2 border-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>
|
||||
<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]
|
||||
})}
|
||||
>
|
||||
<Popup className="custom-popup">
|
||||
<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>
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</MarkerClusterGroup>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
+296
-40
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
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.js';
|
||||
import { ConfirmModal } from './ConfirmModal.js';
|
||||
|
||||
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
|
||||
if (!actual) return null;
|
||||
@@ -19,42 +20,212 @@ const TimeVariance = ({ planned, actual }: { planned: string, actual: string | n
|
||||
);
|
||||
};
|
||||
|
||||
export const ItineraryTimeline = () => {
|
||||
const { legs } = useTourStore();
|
||||
const calculateDistance = (lat1: number, lon1: number, lat2: number, lon2: number) => {
|
||||
const p = 0.017453292519943295; // Math.PI / 180
|
||||
const c = Math.cos;
|
||||
const a = 0.5 - c((lat2 - lat1) * p) / 2 +
|
||||
c(lat1 * p) * c(lat2 * p) *
|
||||
(1 - c((lon2 - lon1) * p)) / 2;
|
||||
return 12742 * Math.asin(Math.sqrt(a)); // 2 * R; R = 6371 km
|
||||
};
|
||||
|
||||
const toggleComplete = async (placeId: number) => {
|
||||
// Gọi API PATCH /api/v1/places/:id để cập nhật trạng thái
|
||||
console.log("Toggle status for place:", placeId);
|
||||
const formatTravelTime = (minutes: number) => {
|
||||
if (minutes < 60) return `${minutes} phút`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours} giờ ${mins} phút` : `${hours} giờ`;
|
||||
};
|
||||
|
||||
export const ItineraryTimeline = ({
|
||||
onAddLocation,
|
||||
onEditLocation
|
||||
}: { 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 (
|
||||
<div className="max-w-2xl mx-auto p-4 sm:p-6 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="space-y-8">
|
||||
{legs.map((leg, legIdx) => (
|
||||
<div key={leg.id} className="relative">
|
||||
{/* 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 className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen">
|
||||
<div className="px-2 pt-4">
|
||||
{legs.length === 0 ? (
|
||||
<div className="text-center py-20 bg-white rounded-3xl border-2 border-dashed border-gray-200">
|
||||
<List className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500 font-medium">Chưa có chặng nào trong lộ trình.</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* 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">
|
||||
{leg.places.map((place) => (
|
||||
<div key={place.id} className="relative flex group">
|
||||
<div className="ml-2">
|
||||
{leg.locations.map((location, idx) => {
|
||||
// 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 */}
|
||||
<div className="z-10 mt-1.5 mr-4">
|
||||
<button
|
||||
onClick={() => toggleComplete(place.id)}
|
||||
className={`transition-colors duration-200 ${place.isCompleted ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`}
|
||||
onClick={() => toggleComplete(location.id)}
|
||||
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" />
|
||||
) : (
|
||||
<Circle className="w-8 h-8 bg-white rounded-full fill-white" />
|
||||
@@ -64,47 +235,132 @@ export const ItineraryTimeline = () => {
|
||||
|
||||
{/* Card Content */}
|
||||
<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>
|
||||
<h3 className={`font-semibold text-lg ${place.isCompleted ? 'text-gray-500 line-through' : 'text-gray-800'}`}>
|
||||
{place.name}
|
||||
{isStartPoint && (
|
||||
<span className="inline-block px-2 py-0.5 bg-green-100 text-green-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm bắt đầu</span>
|
||||
)}
|
||||
{isEndPoint && (
|
||||
<span className="inline-block px-2 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span>
|
||||
)}
|
||||
<h3 className={`font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`}>
|
||||
{location.name}
|
||||
</h3>
|
||||
<div className="flex items-center text-sm text-gray-500 mt-1">
|
||||
<MapPin className="w-3 h-3 mr-1" />
|
||||
<span className="truncate max-w-[200px] sm:max-w-md">{place.address}</span>
|
||||
<span className="truncate max-w-[200px] sm:max-w-md">{location.address}</span>
|
||||
</div>
|
||||
{location.note && (
|
||||
<div className="mt-2 text-xs text-gray-600 bg-gray-50 p-2 rounded-lg border border-gray-100 italic">
|
||||
{location.note}
|
||||
</div>
|
||||
)}
|
||||
{dwellMinutes !== null && (
|
||||
<div className="flex items-center text-xs text-amber-600 font-medium mt-1">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
<span>Thời gian dừng: {formatTravelTime(dwellMinutes)}</span>
|
||||
</div>
|
||||
)}
|
||||
{locationExpense && (
|
||||
<div className="mt-2 text-xs text-indigo-600 bg-indigo-50/80 p-2 rounded-lg border border-indigo-100 space-y-1">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
<Zap className="w-3 h-3" />
|
||||
<span>Chi phí: {Number(locationExpense.amount).toLocaleString()}đ ({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 className="text-right flex flex-col items-end">
|
||||
<div className="flex items-center text-sm font-medium text-blue-600">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
{format(parseISO(place.arrivalTime), 'HH:mm')}
|
||||
{location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
{place.isCompleted && place.completedAt && (
|
||||
{location.status === 'COMPLETED' && location.actualStart && (
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{leg.notes && (
|
||||
<p className="ml-14 mt-4 text-sm text-gray-400 italic">
|
||||
* {leg.notes}
|
||||
</p>
|
||||
{distanceToNext !== null && travelTimeMinutes !== null && (
|
||||
<div className="ml-4 -mt-4 mb-2 flex items-center gap-3">
|
||||
<div className="w-8 flex justify-center">
|
||||
<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>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
+36
-3
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
onBack: () => void;
|
||||
@@ -11,7 +11,9 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
confirmPassword: '',
|
||||
phone: '',
|
||||
address: ''
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -35,7 +37,9 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
|
||||
body: JSON.stringify({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
name: formData.name
|
||||
name: formData.name,
|
||||
phone: formData.phone || undefined,
|
||||
address: formData.address || undefined
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -122,6 +126,35 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
|
||||
</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="space-y-1.5">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Mật khẩu</label>
|
||||
|
||||
+799
-42
@@ -1,52 +1,370 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { ItineraryTimeline } from './ItineraryTimeline.js';
|
||||
import { ExpenseManager } from './ExpenseManager.js';
|
||||
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 {
|
||||
Map as MapIcon,
|
||||
Wallet,
|
||||
Image as ImageIcon,
|
||||
Calendar,
|
||||
Users,
|
||||
ChevronLeft
|
||||
ChevronLeft,
|
||||
Settings,
|
||||
Quote,
|
||||
Plus,
|
||||
List,
|
||||
Map as MapIconLucide,
|
||||
MapPin,
|
||||
Flag,
|
||||
Clock,
|
||||
Check,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import L from 'leaflet';
|
||||
|
||||
export const TourDetailPage = () => {
|
||||
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo'>('plan');
|
||||
const { currentTour, fetchTour, userRole } = useTourStore();
|
||||
// Định nghĩa kiểu dữ liệu cho Địa điểm để khớp với Schema Prisma
|
||||
type LocationType = 'MOVE' | 'VISIT' | 'REST' | 'EAT';
|
||||
|
||||
// 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(() => {
|
||||
// Gọi ID 1 (Dữ liệu từ file seed) để kiểm tra
|
||||
fetchTour(1);
|
||||
}, [fetchTour]);
|
||||
if (locations.length > 0) {
|
||||
const bounds = L.latLngBounds(locations.map(l => [l.latitude, l.longitude]));
|
||||
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
|
||||
const tabs = [
|
||||
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: ['OWNER', 'EDITOR', 'MEMBER_PLAN_ONLY'].includes(userRole || '') },
|
||||
{ id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'EDITOR'].includes(userRole || '') },
|
||||
{ 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);
|
||||
|
||||
// Tự động chuyển tab nếu người dùng không có quyền xem 'plan'
|
||||
useEffect(() => {
|
||||
if (userRole === 'MEMBER_PHOTO_ONLY') {
|
||||
setActiveTab('photo');
|
||||
}
|
||||
}, [userRole]);
|
||||
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)], []);
|
||||
|
||||
// Mock dữ liệu header (Trong thực tế sẽ lấy từ currentTour)
|
||||
const tourInfo = {
|
||||
title: currentTour?.title || "Hành trình khám phá TP.HCM",
|
||||
date: "20 - 21 Tháng 11, 2023",
|
||||
members: 5,
|
||||
budget: "2.500.000 VND"
|
||||
date: currentTour?.startDate ? `${new Date(currentTour.startDate).toLocaleDateString('vi-VN')} - ${new Date(currentTour.endDate).toLocaleDateString('vi-VN')}` : "Chưa xác định ngày",
|
||||
membersCount: currentTour?.participants?.length || 0,
|
||||
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 (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-gray-50 pb-20">
|
||||
{/* 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">
|
||||
<button className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<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 onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<ChevronLeft className="w-6 h-6 text-gray-600" />
|
||||
</button>
|
||||
<h1 className="text-lg font-bold text-gray-800 truncate px-4">
|
||||
@@ -56,29 +374,198 @@ export const TourDetailPage = () => {
|
||||
</div>
|
||||
|
||||
{/* 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="flex flex-wrap gap-4 text-sm opacity-90">
|
||||
<div className="flex items-center">
|
||||
<h2 className="text-3xl font-black tracking-tight drop-shadow-md">{tourInfo.title}</h2>
|
||||
{/* 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" />
|
||||
{tourInfo.date}
|
||||
</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" />
|
||||
{tourInfo.members} thành viên
|
||||
{tourInfo.membersCount} thành viên
|
||||
</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>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="max-w-2xl mx-auto -mt-12 px-4 pb-24">
|
||||
{/* Financial Quick-View Widget or Quote */}
|
||||
<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 */}
|
||||
<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) => (
|
||||
<button
|
||||
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 ${
|
||||
activeTab === tab.id
|
||||
? '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}
|
||||
</button>
|
||||
))}
|
||||
@@ -98,8 +585,84 @@ export const TourDetailPage = () => {
|
||||
{/* Tab Panels */}
|
||||
<div className="transition-opacity duration-300">
|
||||
{activeTab === 'plan' && (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4">
|
||||
<ItineraryTimeline />
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2">
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
@@ -110,28 +673,222 @@ export const TourDetailPage = () => {
|
||||
)}
|
||||
|
||||
{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) => (
|
||||
<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" />
|
||||
<img
|
||||
src={`https://picsum.photos/seed/${i + 10}/400/400`}
|
||||
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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
|
||||
{/* Floating Action Button (Mobile) */}
|
||||
{canEdit && (
|
||||
<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'}
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
```
|
||||
@@ -32,7 +32,7 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
|
||||
if (isOpen) fetchUsers();
|
||||
}, [isOpen]);
|
||||
|
||||
const handleToggleBlock = async (id: number) => {
|
||||
const handleToggleBlock = async (id: string) => {
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
await fetch(`${API_BASE}/api/v1/users/block/${id}`, {
|
||||
@@ -45,7 +45,7 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
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`;
|
||||
|
||||
@@ -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
+6
-1
@@ -4,11 +4,13 @@ import { LandingPage } from './LandingPage.js';
|
||||
import { TourDetailPage } from './TourDetailPage.js';
|
||||
import { ExploreMap } from './ExploreMap.js';
|
||||
import { SignupPage } from './SignupPage.js';
|
||||
import { useTourStore } from './useTourStore.js';
|
||||
const App = () => {
|
||||
const [view, setView] = useState('landing');
|
||||
const [isInitialSetup, setIsInitialSetup] = useState(false);
|
||||
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');
|
||||
@@ -36,7 +38,10 @@ const App = () => {
|
||||
setUser(null);
|
||||
setView('landing');
|
||||
};
|
||||
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 })), view === 'detail' && (_jsx(TourDetailPage, {}))] }));
|
||||
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
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"App.js","sourceRoot":"","sources":["../App.tsx"],"names":[],"mappings":"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"}
|
||||
{"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
+2
-1
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
export declare const ExploreMap: ({ onBack, onLogout, user }: {
|
||||
export declare const ExploreMap: ({ onBack, onLogout, user, onViewTour }: {
|
||||
onBack: () => void;
|
||||
onLogout?: () => void;
|
||||
user?: any;
|
||||
onViewTour: (id: string) => void;
|
||||
}) => React.JSX.Element;
|
||||
|
||||
Vendored
+63
-16
@@ -1,11 +1,14 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
||||
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, Image as ImageIcon, LogOut, Settings } from 'lucide-react';
|
||||
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',
|
||||
@@ -20,33 +23,77 @@ function RecenterMap({ position }) {
|
||||
}, [position, map]);
|
||||
return null;
|
||||
}
|
||||
export const ExploreMap = ({ onBack, onLogout, user }) => {
|
||||
const { publicTours, fetchPublicTours } = useTourStore();
|
||||
const [userPos, setUserPos] = useState([10.7769, 106.7009]);
|
||||
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();
|
||||
navigator.geolocation.getCurrentPosition((pos) => setUserPos([pos.coords.latitude, pos.coords.longitude]), () => console.log("Không thể lấy vị trí người dùng"));
|
||||
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" })] })), _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: 13, className: "h-full w-full", children: [_jsx(TileLayer, { url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", attribution: '\u00A9 OpenStreetMap contributors' }), _jsx(RecenterMap, { position: userPos }), publicTours.map((tour) => {
|
||||
const place = tour.legs?.[0]?.places?.[0];
|
||||
if (!place)
|
||||
return null;
|
||||
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`;
|
||||
return (_jsx(Marker, { position: [place.latitude, place.longitude], icon: L.divIcon({
|
||||
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 p-1 border-2 border-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>
|
||||
<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],
|
||||
}), children: _jsx(Popup, { className: "custom-popup", children: _jsxs("div", { className: "p-1 max-w-[200px]", children: [_jsx("img", { src: tourImage, className: "w-full h-32 object-cover rounded-lg mb-2" }), _jsx("h4", { className: "font-bold text-gray-900 leading-tight mb-1", children: tour.title }), _jsxs("button", { className: "text-xs font-bold text-blue-600 flex items-center gap-1", children: ["Xem chi ti\u1EBFt h\u00ECnh \u1EA3nh ", _jsx(ImageIcon, { className: "w-3 h-3" })] })] }) }) }, tour.id));
|
||||
})] }), _jsx(UserManagementModal, { isOpen: isAdminModalOpen, onClose: () => setIsAdminModalOpen(false) })] }));
|
||||
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
-1
File diff suppressed because one or more lines are too long
Vendored
+4
-1
@@ -1,2 +1,5 @@
|
||||
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
+103
-7
@@ -1,7 +1,9 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import { useState } from 'react';
|
||||
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.js';
|
||||
import { ConfirmModal } from './ConfirmModal.js';
|
||||
const TimeVariance = ({ planned, actual }) => {
|
||||
if (!actual)
|
||||
return null;
|
||||
@@ -9,11 +11,105 @@ const TimeVariance = ({ planned, actual }) => {
|
||||
const isLate = diff > 0;
|
||||
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` })] }));
|
||||
};
|
||||
export const ItineraryTimeline = () => {
|
||||
const { legs } = useTourStore();
|
||||
const toggleComplete = async (placeId) => {
|
||||
console.log("Toggle status for place:", placeId);
|
||||
const 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));
|
||||
};
|
||||
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);
|
||||
};
|
||||
return (_jsxs("div", { className: "max-w-2xl mx-auto p-4 sm:p-6 bg-gray-50 min-h-screen", children: [_jsx("h2", { className: "text-2xl font-bold text-gray-800 mb-8 px-2", children: "L\u1ED9 tr\u00ECnh chuy\u1EBFn \u0111i" }), _jsx("div", { className: "space-y-8", children: legs.map((leg, legIdx) => (_jsxs("div", { className: "relative", children: [_jsxs("div", { className: "flex items-center mb-4 px-2", children: [_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] }), _jsx("div", { className: "ml-4 h-[1px] flex-1 bg-gray-200" })] }), _jsx("div", { className: "absolute left-6 top-10 bottom-0 w-0.5 bg-gray-200 -z-0" }), _jsx("div", { className: "space-y-6 ml-2", children: leg.places.map((place) => (_jsxs("div", { className: "relative flex group", children: [_jsx("div", { className: "z-10 mt-1.5 mr-4", children: _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 ? (_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 ${place.isCompleted ? '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: [_jsx("h3", { className: `font-semibold text-lg ${place.isCompleted ? 'text-gray-500 line-through' : 'text-gray-800'}`, children: place.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: place.address })] })] }), _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" }), format(parseISO(place.arrivalTime), 'HH:mm')] }), place.isCompleted && place.completedAt && (_jsxs("div", { className: "text-[10px] text-gray-400 mt-1 italic", children: ["Th\u1EF1c t\u1EBF: ", format(parseISO(place.completedAt), 'HH:mm')] }))] })] }), _jsx(TimeVariance, { planned: place.arrivalTime, actual: place.completedAt })] })] }, place.id))) }), leg.notes && (_jsxs("p", { className: "ml-14 mt-4 text-sm text-gray-400 italic", children: ["* ", leg.notes] }))] }, leg.id))) })] }));
|
||||
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 }) })] }));
|
||||
};
|
||||
//# sourceMappingURL=ItineraryTimeline.js.map
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+8
-4
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"SignupPage.js","sourceRoot":"","sources":["../SignupPage.tsx"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"SignupPage.js","sourceRoot":"","sources":["../SignupPage.tsx"],"names":[],"mappings":"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"}
|
||||
Vendored
+3
-1
@@ -1,2 +1,4 @@
|
||||
import React from 'react';
|
||||
export declare const TourDetailPage: () => React.JSX.Element;
|
||||
export declare const TourDetailPage: ({ onBack }: {
|
||||
onBack: () => void;
|
||||
}) => React.JSX.Element;
|
||||
|
||||
Vendored
+377
-22
@@ -1,33 +1,388 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { ItineraryTimeline } from './ItineraryTimeline.js';
|
||||
import { ExpenseManager } from './ExpenseManager.js';
|
||||
import { useTourStore } from './useTourStore.js';
|
||||
import { Map as MapIcon, Wallet, Image as ImageIcon, Calendar, Users, ChevronLeft } from 'lucide-react';
|
||||
export const TourDetailPage = () => {
|
||||
const [activeTab, setActiveTab] = useState('plan');
|
||||
const { currentTour, fetchTour, userRole } = useTourStore();
|
||||
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.default || _MarkerClusterGroup;
|
||||
import { useMap } from 'react-leaflet';
|
||||
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';
|
||||
import L from 'leaflet';
|
||||
delete L.Icon.Default.prototype._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',
|
||||
});
|
||||
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]
|
||||
});
|
||||
const MapTourBounds = ({ locations }) => {
|
||||
const map = useMap();
|
||||
const locKey = useMemo(() => JSON.stringify(locations.map(l => [l.latitude, l.longitude])), [locations]);
|
||||
useEffect(() => {
|
||||
fetchTour(1);
|
||||
}, [fetchTour]);
|
||||
const tabs = [
|
||||
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: ['OWNER', 'EDITOR', 'MEMBER_PLAN_ONLY'].includes(userRole || '') },
|
||||
{ id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'EDITOR'].includes(userRole || '') },
|
||||
{ id: 'photo', label: 'Ảnh', icon: ImageIcon, visible: true },
|
||||
].filter(t => t.visible);
|
||||
useEffect(() => {
|
||||
if (userRole === 'MEMBER_PHOTO_ONLY') {
|
||||
setActiveTab('photo');
|
||||
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 = {
|
||||
title: currentTour?.title || "Hành trình khám phá TP.HCM",
|
||||
date: "20 - 21 Tháng 11, 2023",
|
||||
members: 5,
|
||||
budget: "2.500.000 VND"
|
||||
date: currentTour?.startDate ? `${new Date(currentTour.startDate).toLocaleDateString('vi-VN')} - ${new Date(currentTour.endDate).toLocaleDateString('vi-VN')}` : "Chưa xác định ngày",
|
||||
membersCount: currentTour?.participants?.length || 0,
|
||||
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 (_jsxs("div", { className: "min-h-screen bg-white", children: [_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: [_jsx("button", { 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" }), " "] }), _jsx("div", { className: "bg-blue-600 text-white p-6 pb-20", children: _jsxs("div", { className: "max-w-2xl mx-auto space-y-4", children: [_jsxs("div", { className: "flex flex-wrap gap-4 text-sm opacity-90", children: [_jsxs("div", { className: "flex items-center", children: [_jsx(Calendar, { className: "w-4 h-4 mr-1.5" }), tourInfo.date] }), _jsxs("div", { className: "flex items-center", children: [_jsx(Users, { className: "w-4 h-4 mr-1.5" }), tourInfo.members, " th\u00E0nh vi\u00EAn"] })] }), _jsxs("div", { className: "flex items-baseline gap-2", children: [_jsx("span", { className: "text-3xl font-bold", children: tourInfo.budget }), _jsx("span", { className: "text-blue-100 text-sm", children: "d\u1EF1 ki\u1EBFn" })] })] }) }), _jsxs("div", { className: "max-w-2xl mx-auto -mt-12 px-4 pb-24", children: [_jsx("div", { className: "bg-white rounded-2xl shadow-xl border border-gray-100 p-1 flex mb-6", 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
|
||||
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'
|
||||
: 'text-gray-400 hover:text-gray-600'}`, children: [_jsx(tab.icon, { className: `w-4 h-4 mr-2 ${activeTab === tab.id ? 'animate-pulse' : ''}` }), tab.label] }, tab.id))) }), _jsxs("div", { className: "transition-opacity duration-300", children: [activeTab === 'plan' && (_jsx("div", { className: "animate-in fade-in slide-in-from-bottom-4", children: _jsx(ItineraryTimeline, {}) })), 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-2 animate-in fade-in", children: [1, 2, 3, 4, 5, 6].map((i) => (_jsxs("div", { className: "aspect-square bg-gray-100 rounded-lg overflow-hidden relative group", 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" })] }, i))) }))] })] }), _jsx("div", { className: "fixed bottom-6 left-1/2 -translate-x-1/2 z-40", children: _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() })] }));
|
||||
};
|
||||
//# sourceMappingURL=TourDetailPage.js.map
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+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
+4
-2
@@ -7,9 +7,11 @@ export declare class JwtStrategy extends JwtStrategy_base {
|
||||
private prisma;
|
||||
constructor(prisma: PrismaService);
|
||||
validate(payload: any): Promise<{
|
||||
email: string;
|
||||
id: number;
|
||||
name: string | null;
|
||||
address: string | null;
|
||||
id: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
passwordHash: string;
|
||||
avatar: string | null;
|
||||
createdAt: Date;
|
||||
|
||||
Vendored
+1
@@ -1 +1,2 @@
|
||||
import 'reflect-metadata';
|
||||
import 'dotenv/config';
|
||||
|
||||
Vendored
+756
-33
@@ -10,15 +10,16 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
||||
return function (target, key) { decorator(target, key, paramIndex); }
|
||||
};
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseIntPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards } from '@nestjs/common';
|
||||
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service.js';
|
||||
import 'dotenv/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { AdminGuard } from './admin.guard.js';
|
||||
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 {
|
||||
getHello() {
|
||||
return 'Travel Planning API is running!';
|
||||
@@ -64,7 +65,7 @@ let AuthController = class AuthController {
|
||||
};
|
||||
}
|
||||
async signup(body) {
|
||||
const { email, password, name } = 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');
|
||||
@@ -72,8 +73,8 @@ let AuthController = class AuthController {
|
||||
const shouldBeAdmin = userCount === 0;
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
return this.prisma.user.create({
|
||||
data: { email, passwordHash, name, isAdmin: shouldBeAdmin },
|
||||
select: { id: true, email: true, name: true, isAdmin: true }
|
||||
data: { email, passwordHash, name, phone, address, isAdmin: shouldBeAdmin },
|
||||
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -105,15 +106,200 @@ let TourController = class TourController {
|
||||
constructor(prisma) {
|
||||
this.prisma = prisma;
|
||||
}
|
||||
async getPublicTours() {
|
||||
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: {
|
||||
take: 1,
|
||||
orderBy: { sequence: 'asc' },
|
||||
include: {
|
||||
places: { take: 1 }
|
||||
locations: { orderBy: { plannedStart: 'asc' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,13 +309,18 @@ let TourController = class TourController {
|
||||
const tour = await this.prisma.tour.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
members: true,
|
||||
participants: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, name: true, email: true }
|
||||
}
|
||||
}
|
||||
},
|
||||
photos: true,
|
||||
legs: {
|
||||
orderBy: { sequenceNumber: 'asc' },
|
||||
orderBy: { sequence: 'asc' },
|
||||
include: {
|
||||
places: { orderBy: { sequenceInLeg: 'asc' } },
|
||||
expenses: true,
|
||||
locations: { orderBy: { plannedStart: 'asc' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -138,32 +329,558 @@ let TourController = class TourController {
|
||||
throw new NotFoundException(`Không tìm thấy Tour với ID ${id}`);
|
||||
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([
|
||||
Get('explore'),
|
||||
UseGuards(JwtAuthGuard),
|
||||
Post(),
|
||||
__param(0, Body()),
|
||||
__param(1, Req()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", []),
|
||||
__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', ParseIntPipe)),
|
||||
__param(0, Param('id', ParseUUIDPipe)),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Number]),
|
||||
__metadata("design:paramtypes", [String]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], 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([
|
||||
Controller('v1/tours'),
|
||||
__metadata("design:paramtypes", [PrismaService])
|
||||
], 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() {
|
||||
return this.prisma.user.findMany({
|
||||
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true, createdAt: true }
|
||||
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) {
|
||||
@@ -178,55 +895,61 @@ let UserController = class UserController {
|
||||
}
|
||||
async deleteUser(id) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (user?.isAdmin) {
|
||||
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 { success: true };
|
||||
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');
|
||||
return this.prisma.user.update({
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { isBlocked: !user.isBlocked }
|
||||
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", []),
|
||||
__metadata("design:paramtypes", [Object, String]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], UserController.prototype, "getAllUsers", null);
|
||||
__decorate([
|
||||
Patch(':id'),
|
||||
__param(0, Param('id', ParseIntPipe)),
|
||||
__param(0, Param('id', ParseUUIDPipe)),
|
||||
__param(1, Body()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Number, Object]),
|
||||
__metadata("design:paramtypes", [String, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], UserController.prototype, "updateUser", null);
|
||||
__decorate([
|
||||
Delete(':id'),
|
||||
__param(0, Param('id', ParseIntPipe)),
|
||||
__param(0, Param('id', ParseUUIDPipe)),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Number]),
|
||||
__metadata("design:paramtypes", [String]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], UserController.prototype, "deleteUser", null);
|
||||
__decorate([
|
||||
Post('block/:id'),
|
||||
__param(0, Param('id', ParseIntPipe)),
|
||||
__param(0, Param('id', ParseUUIDPipe)),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Number]),
|
||||
__metadata("design:paramtypes", [String]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], UserController.prototype, "toggleBlock", null);
|
||||
UserController = __decorate([
|
||||
Controller('v1/users'),
|
||||
UseGuards(JwtAuthGuard, AdminGuard),
|
||||
__metadata("design:paramtypes", [PrismaService])
|
||||
], UserController);
|
||||
let AppModule = class AppModule {
|
||||
@@ -239,8 +962,8 @@ AppModule = __decorate([
|
||||
signOptions: { expiresIn: '1d' },
|
||||
}),
|
||||
],
|
||||
controllers: [AppController, AuthController, TourController, UserController],
|
||||
providers: [PrismaService, JwtStrategy],
|
||||
controllers: [AppController, AuthController, TourController, UserController, RoutingController, LegController, LocationController],
|
||||
providers: [PrismaService, JwtStrategy, TourRoleGuard],
|
||||
exports: [PrismaService]
|
||||
})
|
||||
], AppModule);
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -4,5 +4,6 @@ export default defineConfig({
|
||||
datasource: {
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
schema: 'prisma/schema.prisma',
|
||||
});
|
||||
//# sourceMappingURL=prisma.config.js.map
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"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;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 { PrismaClient } from '@prisma/client';
|
||||
import 'dotenv/config';
|
||||
export declare class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
private pool;
|
||||
constructor();
|
||||
onModuleInit(): Promise<void>;
|
||||
onModuleDestroy(): Promise<void>;
|
||||
|
||||
Vendored
+1
-6
@@ -11,19 +11,14 @@ import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { Pool } from 'pg';
|
||||
import 'dotenv/config';
|
||||
let PrismaService = class PrismaService extends PrismaClient {
|
||||
constructor() {
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const adapter = new PrismaPg(pool);
|
||||
super({ adapter });
|
||||
this.pool = pool;
|
||||
}
|
||||
async onModuleInit() { await this.$connect(); }
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
await this.pool.end();
|
||||
}
|
||||
async onModuleDestroy() { await this.$disconnect(); }
|
||||
};
|
||||
PrismaService = __decorate([
|
||||
Injectable(),
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"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;AAC1B,OAAO,eAAe,CAAC;AAGhB,IAAM,aAAa,GAAnB,MAAM,aAAc,SAAQ,YAAY;IAG7C;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;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,aAAa;IADzB,UAAU,EAAE;;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
+11
-7
@@ -16,12 +16,16 @@ let TourRoleGuard = class TourRoleGuard {
|
||||
async canActivate(context) {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
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;
|
||||
if (!user || isNaN(tourId)) {
|
||||
if (!user || !tourId) {
|
||||
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: {
|
||||
tourId_userId: {
|
||||
tourId: tourId,
|
||||
@@ -29,14 +33,14 @@ let TourRoleGuard = class TourRoleGuard {
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!membership) {
|
||||
if (!participation) {
|
||||
throw new ForbiddenException("Bạn không phải là thành viên của tour này.");
|
||||
}
|
||||
request.tourMembership = membership;
|
||||
const role = membership.role;
|
||||
request.tourParticipation = participation;
|
||||
const role = participation.role;
|
||||
const isPlanPath = path.includes('/plans');
|
||||
const isExpensePath = path.includes('/expenses');
|
||||
if ((role === 'MEMBER_PHOTO_ONLY' || role === 'VIEWER_EXTERNAL') &&
|
||||
if ((role === 'MEMBER_NO_FINANCE' || role === 'VIEWER_ONLY') &&
|
||||
(isPlanPath || isExpensePath)) {
|
||||
throw new ForbiddenException("Bạn không có quyền xem kế hoạch và chi phí của tour này.");
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"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;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,kBAAkB,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,kBAAkB,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,kBAAkB,CAAC,0DAA0D,CAAC,CAAC;QAC3F,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAlDY,aAAa;IADzB,UAAU,EAAE;qCAEiB,aAAa;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
+16
-15
@@ -2,6 +2,7 @@ import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { Pool } from 'pg';
|
||||
import 'dotenv/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const adapter = new PrismaPg(pool);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
@@ -16,14 +17,15 @@ async function main() {
|
||||
data: {
|
||||
email: 'owner@travel.com',
|
||||
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({
|
||||
data: {
|
||||
email: 'photomember@travel.com',
|
||||
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... ---');
|
||||
@@ -32,11 +34,11 @@ async function main() {
|
||||
title: 'Hành trình khám phá TP.HCM',
|
||||
startDate: new Date('2023-11-20'),
|
||||
endDate: new Date('2023-11-21'),
|
||||
creatorId: owner.id,
|
||||
members: {
|
||||
createdById: owner.id,
|
||||
participants: {
|
||||
create: [
|
||||
{ userId: owner.id, role: 'OWNER' },
|
||||
{ userId: photoMember.id, role: 'MEMBER_PHOTO_ONLY' },
|
||||
{ userId: photoMember.id, role: 'VIEWER_ONLY' },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -45,38 +47,37 @@ async function main() {
|
||||
const leg1 = await prisma.leg.create({
|
||||
data: {
|
||||
tourId: tour.id,
|
||||
sequenceNumber: 1,
|
||||
notes: 'Khám phá lịch sử trung tâm',
|
||||
places: {
|
||||
sequence: 1,
|
||||
note: 'Khám phá lịch sử trung tâm',
|
||||
locations: {
|
||||
create: [
|
||||
{
|
||||
name: 'Dinh Độc Lập',
|
||||
address: '135 Nam Kỳ Khởi Nghĩa, Quận 1',
|
||||
latitude: 10.777,
|
||||
longitude: 106.695,
|
||||
sequenceInLeg: 1,
|
||||
arrivalTime: new Date('2023-11-20T08:00:00Z'),
|
||||
plannedStart: new Date('2023-11-20T08:00:00Z'),
|
||||
},
|
||||
{
|
||||
name: 'Bưu điện Thành phố',
|
||||
address: '02 Công xã Paris, Quận 1',
|
||||
latitude: 10.779,
|
||||
longitude: 106.699,
|
||||
sequenceInLeg: 2,
|
||||
arrivalTime: new Date('2023-11-20T10:00:00Z'),
|
||||
plannedStart: new Date('2023-11-20T10:00:00Z'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log('--- Đang tạo chi phí mẫu... ---');
|
||||
await prisma.expense.create({
|
||||
const expense1 = await prisma.expense.create({
|
||||
data: {
|
||||
legId: leg1.id,
|
||||
category: 'DINING',
|
||||
category: 'FOOD',
|
||||
amount: 500000,
|
||||
currency: 'VND',
|
||||
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! ---');
|
||||
|
||||
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
+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
+27
-2
@@ -3,11 +3,36 @@ interface TourState {
|
||||
legs: any[];
|
||||
publicTours: any[];
|
||||
userRole: string | null;
|
||||
activeLegId: string | null;
|
||||
mapCenter: [number, number];
|
||||
setTour: (tour: any) => void;
|
||||
updateLegs: (legs: any[]) => void;
|
||||
optimizeRouting: () => Promise<void>;
|
||||
fetchTour: (id: number) => Promise<void>;
|
||||
createTour: (tourData: any) => Promise<any>;
|
||||
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 {};
|
||||
|
||||
Vendored
+327
-6
@@ -4,21 +4,342 @@ export const useTourStore = create((set, get) => ({
|
||||
legs: [],
|
||||
publicTours: [],
|
||||
userRole: null,
|
||||
activeLegId: null,
|
||||
mapCenter: [10.7769, 106.7009],
|
||||
setTour: (tour) => set({ currentTour: tour }),
|
||||
updateLegs: (legs) => set({ legs }),
|
||||
setActiveLegId: (id) => set({ activeLegId: id }),
|
||||
setMapCenter: (pos) => set({ mapCenter: pos }),
|
||||
fetchTour: async (id) => {
|
||||
const currentUserId = 1;
|
||||
const response = await fetch(`http://localhost:3001/api/v1/tours/${id}`);
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
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 role = data.members?.find((m) => m.userId === currentUserId)?.role || 'VIEWER_EXTERNAL';
|
||||
set({ currentTour: data, legs: data.legs || [], userRole: role });
|
||||
let role = 'VIEWER_ONLY';
|
||||
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);
|
||||
}
|
||||
},
|
||||
fetchPublicTours: async () => {
|
||||
const response = await fetch(`http://localhost:3001/api/v1/tours/explore`);
|
||||
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 });
|
||||
},
|
||||
optimizeRouting: async () => {
|
||||
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
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
# docs/help/
|
||||
Help snippet index for this project.
|
||||
@@ -7,3 +7,21 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseIntPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards } from '@nestjs/common';
|
||||
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service.js';
|
||||
import 'dotenv/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
@@ -7,6 +8,7 @@ import { AdminGuard } from './admin.guard.js';
|
||||
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';
|
||||
|
||||
@Controller()
|
||||
class AppController {
|
||||
@@ -56,22 +58,19 @@ class AuthController {
|
||||
|
||||
@Post('signup')
|
||||
async signup(@Body() body: any) {
|
||||
const { email, password, name } = body;
|
||||
const { email, password, name, phone, address } = body;
|
||||
|
||||
// 1. Kiểm tra email tồn tại
|
||||
const existingUser = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (existingUser) throw new BadRequestException('Email đã được sử dụng');
|
||||
|
||||
// 2. Logic "First User is Admin"
|
||||
const userCount = await this.prisma.user.count();
|
||||
const shouldBeAdmin = userCount === 0;
|
||||
|
||||
// 3. Hash mật khẩu
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
return this.prisma.user.create({
|
||||
data: { email, passwordHash, name, isAdmin: shouldBeAdmin },
|
||||
select: { id: true, email: true, name: true, isAdmin: true }
|
||||
data: { email, passwordHash, name, phone, address, isAdmin: shouldBeAdmin },
|
||||
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -80,35 +79,279 @@ class AuthController {
|
||||
class TourController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post()
|
||||
async createTour(@Body() body: any, @Req() req: any) {
|
||||
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 } } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Post(':tourId/locations')
|
||||
async addLocation(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Post(':tourId/start-point')
|
||||
async updateTourStartPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) {
|
||||
const { latitude, longitude, name } = body;
|
||||
|
||||
console.log(`[USER ACTION] User ${req.user.id} set START point for Tour ${tourId}: "${name}" at [${latitude}, ${longitude}]`);
|
||||
|
||||
// 1. Xóa tất cả các điểm bắt đầu cũ của Tour này (được đánh dấu bằng plannedStart = 0)
|
||||
// để đảm bảo tính duy nhất và sạch sẽ của dữ liệu.
|
||||
await this.prisma.location.deleteMany({
|
||||
where: {
|
||||
leg: { tourId: tourId },
|
||||
plannedStart: new Date(0)
|
||||
}
|
||||
});
|
||||
|
||||
// Tìm chặng đầu tiên của tour để ghim điểm xuất phát
|
||||
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');
|
||||
|
||||
// 2. Tạo mới điểm xuất phát tại Chặng 1
|
||||
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),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Post(':tourId/end-point')
|
||||
async updateTourEndPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) {
|
||||
const { latitude, longitude, name } = body;
|
||||
|
||||
console.log(`[USER ACTION] User ${req.user.id} set END point for Tour ${tourId}: "${name}" at [${latitude}, ${longitude}]`);
|
||||
|
||||
// Xóa điểm kết thúc cũ (được đánh dấu bằng plannedEnd = 0) để tránh trùng lặp ghim trên bản đồ
|
||||
await this.prisma.location.deleteMany({
|
||||
where: {
|
||||
leg: { tourId: tourId },
|
||||
plannedEnd: new Date(0)
|
||||
}
|
||||
});
|
||||
|
||||
// Tìm chặng cuối cùng của tour để ghim điểm kết thúc
|
||||
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), // Đánh dấu đây là điểm kết thúc đặc biệt
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Post(':tourId/legs/batch')
|
||||
async initializeLegs(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { count: number }) {
|
||||
const { count } = body;
|
||||
if (count <= 0 || count > 20) throw new BadRequestException('Số lượng chặng không hợp lệ (1-20)');
|
||||
|
||||
// 1. Lấy danh sách chặng hiện có
|
||||
const existingLegs = await this.prisma.leg.findMany({
|
||||
where: { tourId },
|
||||
orderBy: { sequence: 'asc' }
|
||||
});
|
||||
|
||||
// 2. Tạo thêm chặng nếu số lượng hiện tại chưa đủ 'count'
|
||||
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 });
|
||||
}
|
||||
|
||||
// 3. Lấy chặng cuối cùng sau khi đã cập nhật
|
||||
const allLegs = await this.prisma.leg.findMany({
|
||||
where: { tourId },
|
||||
orderBy: { sequence: 'asc' }
|
||||
});
|
||||
const lastLeg = allLegs[allLegs.length - 1];
|
||||
|
||||
// 4. Tự động di chuyển Điểm kết thúc sang Chặng cuối cùng (nếu đã khai báo điểm kết thúc)
|
||||
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;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Post(':tourId/legs')
|
||||
async addLeg(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any) {
|
||||
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}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Patch(':id')
|
||||
async updateTour(@Param('id', ParseUUIDPipe) id: string, @Body() body: any) {
|
||||
// Lưu ý: Trong thực tế nên kiểm tra xem user có phải là OWNER không
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Delete(':id')
|
||||
async deleteTour(@Param('id', ParseUUIDPipe) id: string) {
|
||||
// Xóa tour sẽ xóa cascade các Leg, Location, Expense nhờ config onDelete: Cascade trong schema
|
||||
await this.prisma.tour.delete({
|
||||
where: { id },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('explore')
|
||||
async getPublicTours() {
|
||||
// Lấy các tour có ít nhất 1 ảnh và thông tin vị trí từ chặng đầu tiên
|
||||
async getPublicTours(@Req() req: any) {
|
||||
// Lọc Tour: Chỉ lấy những tour mà người dùng hiện tại là thành viên (Participant)
|
||||
return this.prisma.tour.findMany({
|
||||
where: {
|
||||
participants: {
|
||||
some: { userId: req.user.id }
|
||||
}
|
||||
},
|
||||
take: 20,
|
||||
include: {
|
||||
photos: { take: 1 },
|
||||
legs: {
|
||||
take: 1,
|
||||
orderBy: { sequence: 'asc' },
|
||||
include: {
|
||||
places: { take: 1 }
|
||||
locations: { orderBy: { plannedStart: 'asc' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Get(':id')
|
||||
async getTourDetails(@Param('id', ParseIntPipe) id: number) {
|
||||
async getTourDetails(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const tour = await this.prisma.tour.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
members: true,
|
||||
participants: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, name: true, email: true }
|
||||
}
|
||||
}
|
||||
},
|
||||
photos: true,
|
||||
legs: {
|
||||
orderBy: { sequenceNumber: 'asc' },
|
||||
orderBy: { sequence: 'asc' },
|
||||
include: {
|
||||
places: { orderBy: { sequenceInLeg: 'asc' } },
|
||||
expenses: true,
|
||||
locations: { orderBy: { plannedStart: 'asc' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -117,23 +360,444 @@ class TourController {
|
||||
if (!tour) throw new NotFoundException(`Không tìm thấy Tour với ID ${id}`);
|
||||
return tour;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Post(':tourId/members')
|
||||
async addMember(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { userId: string; role?: string }, @Req() req: any) {
|
||||
const validRoles = ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY'] as const;
|
||||
const role = validRoles.includes(body.role as any) ? body.role as 'OWNER' | 'MANAGER' | 'MEMBER' | 'MEMBER_NO_FINANCE' | 'VIEWER_ONLY' : '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 } } },
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Get(':tourId/join-requests')
|
||||
async getJoinRequests(@Param('tourId', ParseUUIDPipe) tourId: string, @Req() req: any) {
|
||||
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;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Post(':tourId/join-requests')
|
||||
async createJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { userId?: string }, @Req() req: any) {
|
||||
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;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Post(':tourId/join-requests/:requestId/accept')
|
||||
async acceptJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('requestId') requestId: string, @Req() req: any) {
|
||||
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.' };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Post(':tourId/join-requests/:requestId/reject')
|
||||
async rejectJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('requestId') requestId: string, @Req() req: any) {
|
||||
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.' };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||
@Delete(':tourId/members/:userId')
|
||||
async removeMember(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('userId', ParseUUIDPipe) userId: string) {
|
||||
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' };
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('v1/locations')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
class LocationController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
@Patch(':id')
|
||||
async updateLocation(@Param('id', ParseUUIDPipe) id: string, @Body() body: any) {
|
||||
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;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteLocation(@Param('id', ParseUUIDPipe) id: string) {
|
||||
await this.prisma.location.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('v1/legs')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
class LegController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
@Patch(':id')
|
||||
async updateLeg(@Param('id', ParseUUIDPipe) id: string, @Body() body: any) {
|
||||
return this.prisma.leg.update({
|
||||
where: { id },
|
||||
data: {
|
||||
note: body.note,
|
||||
sequence: body.sequence
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteLeg(@Param('id', ParseUUIDPipe) id: string) {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper tính khoảng cách giữa 2 tọa độ (Haversine formula)
|
||||
*/
|
||||
function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number) {
|
||||
const p = 0.017453292519943295; // Math.PI / 180
|
||||
const c = Math.cos;
|
||||
const a = 0.5 - c((lat2 - lat1) * p) / 2 +
|
||||
c(lat1 * p) * c(lat2 * p) *
|
||||
(1 - c((lon2 - lon1) * p)) / 2;
|
||||
return 12742 * Math.asin(Math.sqrt(a)); // 2 * R; R = 6371 km
|
||||
}
|
||||
|
||||
@Controller('v1/routing')
|
||||
class RoutingController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
@Post('optimize/:legId')
|
||||
async optimize(@Param('legId', ParseUUIDPipe) legId: string) {
|
||||
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 };
|
||||
|
||||
// KHAI BÁO startAnchor ở đầu hàm để tránh lỗi scope TS2304
|
||||
let startAnchor: any = null;
|
||||
|
||||
// Tìm địa điểm cuối cùng của chặng trước đó
|
||||
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 };
|
||||
|
||||
// Thuật toán Greedy TSP đơn giản để tối ưu hóa lộ trình
|
||||
const optimized = [];
|
||||
const unvisited = [...locations];
|
||||
|
||||
// Bắt đầu với địa điểm có thời gian dự kiến sớm nhất hiện tại
|
||||
let current: any;
|
||||
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);
|
||||
}
|
||||
|
||||
// Tính toán tổng quãng đường di chuyển của chặng (km)
|
||||
let totalDistance = 0;
|
||||
|
||||
// Cộng thêm quãng đường từ chặng trước nối sang chặng này
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
// Cập nhật lại thời gian plannedStart trong DB để phản ánh thứ tự mới (mỗi điểm cách nhau 1 giờ giả định)
|
||||
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))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('v1/users')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
class UserController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
async getAllUsers() {
|
||||
return this.prisma.user.findMany({
|
||||
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true, createdAt: true }
|
||||
async getAllUsers(@Req() req: any, @Query('q') q?: string) {
|
||||
const currentUserId = req.user?.sub;
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: q
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: q, mode: 'insensitive' as any } },
|
||||
{ email: { contains: q, mode: 'insensitive' as any } },
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true, createdAt: true, phone: true, address: true }
|
||||
});
|
||||
return users.filter((u: any) => u.id !== currentUserId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async updateUser(@Param('id', ParseIntPipe) id: number, @Body() data: any) {
|
||||
// Nếu đổi mật khẩu thì cần hash
|
||||
async updateUser(@Param('id', ParseUUIDPipe) id: string, @Body() data: any) {
|
||||
if (data.password) {
|
||||
data.passwordHash = await bcrypt.hash(data.password, 10);
|
||||
delete data.password;
|
||||
@@ -146,25 +810,28 @@ class UserController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteUser(@Param('id', ParseIntPipe) id: number) {
|
||||
// Không cho phép tự xóa chính mình hoặc xóa admin cuối cùng (logic đơn giản)
|
||||
async deleteUser(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (user?.isAdmin) {
|
||||
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 { success: true };
|
||||
return { message: 'Đã xóa người dùng' };
|
||||
}
|
||||
|
||||
@Post('block/:id')
|
||||
async toggleBlock(@Param('id', ParseIntPipe) id: number) {
|
||||
async toggleBlock(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!user) throw new NotFoundException('Người dùng không tồn tại');
|
||||
return this.prisma.user.update({
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { isBlocked: !user.isBlocked }
|
||||
data: { isBlocked: !user.isBlocked },
|
||||
select: { id: true, email: true, name: true, isBlocked: true }
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,8 +842,8 @@ class UserController {
|
||||
signOptions: { expiresIn: '1d' },
|
||||
}),
|
||||
],
|
||||
controllers: [AppController, AuthController, TourController, UserController],
|
||||
providers: [PrismaService, JwtStrategy],
|
||||
controllers: [AppController, AuthController, TourController, UserController, RoutingController, LegController, LocationController],
|
||||
providers: [PrismaService, JwtStrategy, TourRoleGuard],
|
||||
exports: [PrismaService]
|
||||
})
|
||||
class AppModule {}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MemberRole" AS ENUM ('OWNER', 'EDITOR', 'MEMBER_PLAN_ONLY', 'MEMBER_PHOTO_ONLY', 'VIEWER_EXTERNAL');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ExpenseCategory" AS ENUM ('LODGING', 'DINING', 'TRANSPORT', 'OTHER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TriggerType" AS ENUM ('AUTO_BY_TIME', 'MANUAL_BY_USER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PrivacyLevel" AS ENUM ('PUBLIC_IN_TOUR', 'PRIVATE_OWNER');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"avatar" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Tour" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"startDate" TIMESTAMP(3),
|
||||
"endDate" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"creatorId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Tour_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TourMember" (
|
||||
"tourId" INTEGER NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"role" "MemberRole" NOT NULL DEFAULT 'MEMBER_PLAN_ONLY',
|
||||
|
||||
CONSTRAINT "TourMember_pkey" PRIMARY KEY ("tourId","userId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Leg" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"tourId" INTEGER NOT NULL,
|
||||
"sequenceNumber" INTEGER NOT NULL,
|
||||
"notes" TEXT,
|
||||
|
||||
CONSTRAINT "Leg_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Place" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"legId" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"address" TEXT,
|
||||
"latitude" DOUBLE PRECISION NOT NULL,
|
||||
"longitude" DOUBLE PRECISION NOT NULL,
|
||||
"sequenceInLeg" INTEGER NOT NULL,
|
||||
"arrivalTime" TIMESTAMP(3),
|
||||
"departureTime" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "Place_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Expense" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"legId" INTEGER NOT NULL,
|
||||
"placeId" INTEGER,
|
||||
"category" "ExpenseCategory" NOT NULL,
|
||||
"amount" DECIMAL(15,2) NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'VND',
|
||||
"description" TEXT,
|
||||
|
||||
CONSTRAINT "Expense_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Task" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"tourId" INTEGER NOT NULL,
|
||||
"legId" INTEGER,
|
||||
"title" TEXT NOT NULL,
|
||||
"plannedTimestamp" TIMESTAMP(3) NOT NULL,
|
||||
"isCompleted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"triggerType" "TriggerType" NOT NULL DEFAULT 'MANUAL_BY_USER',
|
||||
|
||||
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Photo" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"tourId" INTEGER NOT NULL,
|
||||
"placeId" INTEGER,
|
||||
"uploaderId" INTEGER NOT NULL,
|
||||
"imageUrl" TEXT NOT NULL,
|
||||
"capturedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"privacyLevel" "PrivacyLevel" NOT NULL DEFAULT 'PUBLIC_IN_TOUR',
|
||||
|
||||
CONSTRAINT "Photo_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Tour" ADD CONSTRAINT "Tour_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TourMember" ADD CONSTRAINT "TourMember_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TourMember" ADD CONSTRAINT "TourMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Leg" ADD CONSTRAINT "Leg_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Place" ADD CONSTRAINT "Place_legId_fkey" FOREIGN KEY ("legId") REFERENCES "Leg"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_legId_fkey" FOREIGN KEY ("legId") REFERENCES "Leg"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_placeId_fkey" FOREIGN KEY ("placeId") REFERENCES "Place"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Task" ADD CONSTRAINT "Task_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Task" ADD CONSTRAINT "Task_legId_fkey" FOREIGN KEY ("legId") REFERENCES "Leg"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_placeId_fkey" FOREIGN KEY ("placeId") REFERENCES "Place"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_uploaderId_fkey" FOREIGN KEY ("uploaderId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -1,14 +0,0 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Expense" DROP CONSTRAINT "Expense_legId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Expense" DROP CONSTRAINT "Expense_placeId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "isAdmin" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_legId_fkey" FOREIGN KEY ("legId") REFERENCES "Leg"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_placeId_fkey" FOREIGN KEY ("placeId") REFERENCES "Place"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "isBlocked" BOOLEAN NOT NULL DEFAULT false;
|
||||
Generated
+118
-93
@@ -26,6 +26,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-leaflet-cluster": "^2.1.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1",
|
||||
"zustand": "^5.0.1"
|
||||
@@ -281,14 +282,14 @@
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz",
|
||||
"integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@electric-sql/pglite-socket": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz",
|
||||
"integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"pglite-server": "dist/scripts/server.js"
|
||||
@@ -301,7 +302,7 @@
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz",
|
||||
"integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"@electric-sql/pglite": "0.4.1"
|
||||
@@ -787,7 +788,7 @@
|
||||
"version": "1.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
|
||||
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
@@ -964,7 +965,7 @@
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ljharb/through": {
|
||||
@@ -1293,7 +1294,7 @@
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz",
|
||||
"integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"c12": "3.3.4",
|
||||
@@ -1312,7 +1313,7 @@
|
||||
"version": "0.24.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz",
|
||||
"integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@electric-sql/pglite": "0.4.1",
|
||||
@@ -1347,7 +1348,7 @@
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz",
|
||||
"integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -1361,14 +1362,14 @@
|
||||
"version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz",
|
||||
"integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/engines/node_modules/@prisma/get-platform": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz",
|
||||
"integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "7.8.0"
|
||||
@@ -1378,7 +1379,7 @@
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz",
|
||||
"integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "7.8.0",
|
||||
@@ -1390,7 +1391,7 @@
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz",
|
||||
"integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "7.8.0"
|
||||
@@ -1400,7 +1401,7 @@
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz",
|
||||
"integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "7.2.0"
|
||||
@@ -1410,21 +1411,21 @@
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz",
|
||||
"integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/query-plan-executor": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz",
|
||||
"integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/streams-local": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz",
|
||||
"integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ajv": "^8.12.0",
|
||||
@@ -1441,7 +1442,7 @@
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz",
|
||||
"integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-toggle": "1.1.10",
|
||||
@@ -1461,7 +1462,7 @@
|
||||
"version": "1.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
|
||||
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
@@ -1487,7 +1488,7 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
@@ -1511,14 +1512,14 @@
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -1534,7 +1535,7 @@
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
@@ -1553,7 +1554,7 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||
@@ -1573,7 +1574,7 @@
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
|
||||
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
@@ -1592,7 +1593,7 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -1883,7 +1884,7 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
@@ -2403,7 +2404,7 @@
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
@@ -2424,7 +2425,7 @@
|
||||
"version": "18.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
@@ -2726,7 +2727,7 @@
|
||||
"version": "8.12.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
|
||||
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
@@ -2929,7 +2930,7 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
@@ -2993,7 +2994,7 @@
|
||||
"version": "2.9.2",
|
||||
"resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz",
|
||||
"integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
@@ -3179,7 +3180,7 @@
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
|
||||
"integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^5.0.0",
|
||||
@@ -3208,7 +3209,7 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
@@ -3224,7 +3225,7 @@
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -3237,7 +3238,7 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
@@ -3354,7 +3355,7 @@
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
@@ -3550,7 +3551,7 @@
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/console-control-strings": {
|
||||
@@ -3654,7 +3655,7 @@
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
@@ -3669,7 +3670,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
@@ -3719,7 +3720,7 @@
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
@@ -3760,7 +3761,7 @@
|
||||
"version": "6.1.7",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
||||
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delegates": {
|
||||
@@ -3773,7 +3774,7 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
@@ -3792,7 +3793,7 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
@@ -3856,7 +3857,7 @@
|
||||
"version": "3.20.0",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
|
||||
"integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
@@ -3880,7 +3881,7 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
|
||||
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -3913,7 +3914,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
|
||||
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
@@ -4189,7 +4190,7 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/external-editor": {
|
||||
@@ -4211,7 +4212,7 @@
|
||||
"version": "3.23.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
||||
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -4234,7 +4235,7 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
@@ -4340,7 +4341,7 @@
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
@@ -4521,7 +4522,7 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-property": "^1.0.2"
|
||||
@@ -4555,7 +4556,7 @@
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz",
|
||||
"integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
@@ -4575,7 +4576,7 @@
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/giget/-/giget-3.3.0.tgz",
|
||||
"integrity": "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"giget": "dist/cli.mjs"
|
||||
@@ -4665,21 +4666,21 @@
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/grammex": {
|
||||
"version": "3.1.12",
|
||||
"resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz",
|
||||
"integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/graphmatch": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz",
|
||||
"integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
@@ -4749,7 +4750,7 @@
|
||||
"version": "4.12.25",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz",
|
||||
"integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -4779,7 +4780,7 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz",
|
||||
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
@@ -4980,7 +4981,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-unicode-supported": {
|
||||
@@ -5000,7 +5001,7 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/iterare": {
|
||||
@@ -5063,7 +5064,7 @@
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
@@ -5109,7 +5110,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
@@ -5194,6 +5195,15 @@
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/leaflet.markercluster": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
|
||||
"integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -5565,7 +5575,7 @@
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
@@ -5591,7 +5601,7 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
||||
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"bun": ">=1.0.0",
|
||||
@@ -5857,7 +5867,7 @@
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
|
||||
"integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.1",
|
||||
@@ -5878,7 +5888,7 @@
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@@ -5895,7 +5905,7 @@
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
|
||||
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lru.min": "^1.1.0"
|
||||
@@ -6055,7 +6065,7 @@
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
||||
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
@@ -6226,7 +6236,7 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -6273,7 +6283,7 @@
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pause": {
|
||||
@@ -6285,7 +6295,7 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
|
||||
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
@@ -6410,7 +6420,7 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
|
||||
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.2.4",
|
||||
@@ -6468,7 +6478,7 @@
|
||||
"version": "3.4.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz",
|
||||
"integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Unlicense",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -6521,7 +6531,7 @@
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz",
|
||||
"integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -6555,7 +6565,7 @@
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
||||
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
@@ -6567,7 +6577,7 @@
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
@@ -6587,7 +6597,7 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -6597,7 +6607,7 @@
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -6669,7 +6679,7 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz",
|
||||
"integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"defu": "^6.1.6",
|
||||
@@ -6724,6 +6734,21 @@
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-leaflet-cluster": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet-cluster/-/react-leaflet-cluster-2.1.0.tgz",
|
||||
"integrity": "sha512-16X7XQpRThQFC4PH4OpXHimGg19ouWmjxjtpxOeBKpvERSvIRqTx7fvhTwkEPNMFTQ8zTfddz6fRTUmUEQul7g==",
|
||||
"license": "SEE LICENSE IN <LICENSE>",
|
||||
"dependencies": {
|
||||
"leaflet.markercluster": "^1.5.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.8.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"react-leaflet": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
@@ -6774,7 +6799,7 @@
|
||||
"version": "2.33.4",
|
||||
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
|
||||
"integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/remeda"
|
||||
@@ -6794,7 +6819,7 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -6835,7 +6860,7 @@
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
@@ -7093,7 +7118,7 @@
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
|
||||
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==",
|
||||
"devOptional": true
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "2.2.1",
|
||||
@@ -7148,7 +7173,7 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
@@ -7161,7 +7186,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -7243,7 +7268,7 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -7306,7 +7331,7 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
|
||||
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
@@ -7325,7 +7350,7 @@
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
@@ -7846,7 +7871,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -7940,7 +7965,7 @@
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"punycode": "^2.1.0"
|
||||
@@ -7965,7 +7990,7 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz",
|
||||
"integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5"
|
||||
@@ -8186,7 +8211,7 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
@@ -8276,7 +8301,7 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz",
|
||||
"integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"grammex": "^3.1.11",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-leaflet-cluster": "^2.1.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1",
|
||||
"zustand": "^5.0.1"
|
||||
|
||||
@@ -6,4 +6,5 @@ export default defineConfig({
|
||||
// Prisma CLI sẽ sử dụng url này để thực hiện migrate
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
schema: 'prisma/schema.prisma',
|
||||
});
|
||||
+1
-8
@@ -2,22 +2,15 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { Pool } from 'pg';
|
||||
import 'dotenv/config';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
private pool: Pool;
|
||||
|
||||
constructor() {
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const adapter = new PrismaPg(pool);
|
||||
super({ adapter });
|
||||
this.pool = pool;
|
||||
}
|
||||
|
||||
async onModuleInit() { await this.$connect(); }
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
await this.pool.end();
|
||||
}
|
||||
async onModuleDestroy() { await this.$disconnect(); }
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ParticipantRole" AS ENUM ('OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "JoinRequestStatus" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ExpenseCategory" AS ENUM ('ACCOMMODATION', 'FOOD', 'TRANSPORT', 'TICKET', 'OTHER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LocationStatus" AS ENUM ('PENDING', 'COMPLETED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LocationType" AS ENUM ('MOVE', 'VISIT', 'REST', 'EAT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PrivacyLevel" AS ENUM ('PUBLIC', 'TOUR_ONLY', 'PRIVATE');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"phone" TEXT,
|
||||
"address" TEXT,
|
||||
"avatar" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isBlocked" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Tour" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"startDate" TIMESTAMP(3),
|
||||
"endDate" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"totalCost" DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||
"createdById" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Tour_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "JoinRequest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tourId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"requestedById" TEXT NOT NULL,
|
||||
"status" "JoinRequestStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "JoinRequest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TourParticipant" (
|
||||
"tourId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"role" "ParticipantRole" NOT NULL DEFAULT 'MEMBER',
|
||||
|
||||
CONSTRAINT "TourParticipant_pkey" PRIMARY KEY ("tourId","userId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Leg" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tourId" TEXT NOT NULL,
|
||||
"sequence" INTEGER NOT NULL,
|
||||
"note" TEXT,
|
||||
|
||||
CONSTRAINT "Leg_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Location" (
|
||||
"id" TEXT NOT NULL,
|
||||
"legId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"address" TEXT,
|
||||
"latitude" DOUBLE PRECISION NOT NULL,
|
||||
"longitude" DOUBLE PRECISION NOT NULL,
|
||||
"plannedStart" TIMESTAMP(3),
|
||||
"plannedEnd" TIMESTAMP(3),
|
||||
"actualStart" TIMESTAMP(3),
|
||||
"actualEnd" TIMESTAMP(3),
|
||||
"status" "LocationStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"type" "LocationType" NOT NULL DEFAULT 'VISIT',
|
||||
|
||||
CONSTRAINT "Location_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Expense" (
|
||||
"id" TEXT NOT NULL,
|
||||
"leg_id" TEXT NOT NULL,
|
||||
"location_id" TEXT,
|
||||
"category" "ExpenseCategory" NOT NULL,
|
||||
"amount" DECIMAL(15,2) NOT NULL,
|
||||
"description" TEXT,
|
||||
"note" TEXT,
|
||||
"paid_by_id" TEXT,
|
||||
|
||||
CONSTRAINT "Expense_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Photo" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tourId" TEXT NOT NULL,
|
||||
"locationId" TEXT,
|
||||
"uploaderId" TEXT NOT NULL,
|
||||
"imageUrl" TEXT NOT NULL,
|
||||
"capturedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"metadata" JSONB,
|
||||
"privacy" "PrivacyLevel" NOT NULL DEFAULT 'TOUR_ONLY',
|
||||
|
||||
CONSTRAINT "Photo_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "JoinRequest_tourId_status_idx" ON "JoinRequest"("tourId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "JoinRequest_userId_idx" ON "JoinRequest"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Tour" ADD CONSTRAINT "Tour_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JoinRequest" ADD CONSTRAINT "JoinRequest_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JoinRequest" ADD CONSTRAINT "JoinRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JoinRequest" ADD CONSTRAINT "JoinRequest_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TourParticipant" ADD CONSTRAINT "TourParticipant_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TourParticipant" ADD CONSTRAINT "TourParticipant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Leg" ADD CONSTRAINT "Leg_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Location" ADD CONSTRAINT "Location_legId_fkey" FOREIGN KEY ("legId") REFERENCES "Leg"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_leg_id_fkey" FOREIGN KEY ("leg_id") REFERENCES "Leg"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_location_id_fkey" FOREIGN KEY ("location_id") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_paid_by_id_fkey" FOREIGN KEY ("paid_by_id") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_uploaderId_fkey" FOREIGN KEY ("uploaderId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,180 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
// --- Enums ---
|
||||
|
||||
enum ParticipantRole {
|
||||
OWNER
|
||||
MANAGER
|
||||
MEMBER
|
||||
MEMBER_NO_FINANCE
|
||||
VIEWER_ONLY
|
||||
}
|
||||
|
||||
enum JoinRequestStatus {
|
||||
PENDING
|
||||
ACCEPTED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum ExpenseCategory {
|
||||
ACCOMMODATION
|
||||
FOOD
|
||||
TRANSPORT
|
||||
TICKET
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum LocationStatus {
|
||||
PENDING
|
||||
COMPLETED
|
||||
}
|
||||
|
||||
enum LocationType {
|
||||
MOVE
|
||||
VISIT
|
||||
REST
|
||||
EAT
|
||||
}
|
||||
|
||||
enum PrivacyLevel {
|
||||
PUBLIC
|
||||
TOUR_ONLY
|
||||
PRIVATE
|
||||
}
|
||||
|
||||
// --- Models ---
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
name String?
|
||||
phone String?
|
||||
address String?
|
||||
avatar String?
|
||||
createdAt DateTime @default(now())
|
||||
isAdmin Boolean @default(false)
|
||||
isBlocked Boolean @default(false)
|
||||
|
||||
createdTours Tour[] @relation("TourCreator")
|
||||
tourParticipations TourParticipant[]
|
||||
requestedJoinRequests JoinRequest[] @relation("JoinRequestUser")
|
||||
receivedJoinRequests JoinRequest[] @relation("JoinRequester")
|
||||
uploadedPhotos Photo[]
|
||||
paidExpenses Expense[] @relation("ExpensePaidBy")
|
||||
}
|
||||
|
||||
model Tour {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
startDate DateTime?
|
||||
endDate DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
totalCost Decimal @default(0) @db.Decimal(15, 2)
|
||||
|
||||
createdById String
|
||||
creator User @relation("TourCreator", fields: [createdById], references: [id])
|
||||
|
||||
participants TourParticipant[]
|
||||
joinRequests JoinRequest[]
|
||||
legs Leg[]
|
||||
photos Photo[]
|
||||
}
|
||||
|
||||
model JoinRequest {
|
||||
id String @id @default(uuid())
|
||||
tourId String
|
||||
userId String
|
||||
requestedById String
|
||||
status JoinRequestStatus @default(PENDING)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||
user User @relation("JoinRequestUser", fields: [userId], references: [id], onDelete: Cascade)
|
||||
requestedBy User @relation("JoinRequester", fields: [requestedById], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tourId, status])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model TourParticipant {
|
||||
tourId String
|
||||
userId String
|
||||
role ParticipantRole @default(MEMBER)
|
||||
|
||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([tourId, userId])
|
||||
}
|
||||
|
||||
model Leg {
|
||||
id String @id @default(uuid())
|
||||
tourId String
|
||||
sequence Int
|
||||
note String? @db.Text
|
||||
|
||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||
locations Location[]
|
||||
expenses Expense[]
|
||||
}
|
||||
|
||||
model Location {
|
||||
id String @id @default(uuid())
|
||||
legId String
|
||||
name String
|
||||
address String?
|
||||
latitude Float
|
||||
longitude Float
|
||||
|
||||
plannedStart DateTime?
|
||||
plannedEnd DateTime?
|
||||
actualStart DateTime?
|
||||
actualEnd DateTime?
|
||||
|
||||
status LocationStatus @default(PENDING)
|
||||
type LocationType @default(VISIT)
|
||||
|
||||
leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade)
|
||||
expenses Expense[]
|
||||
photos Photo[]
|
||||
}
|
||||
|
||||
model Expense {
|
||||
id String @id @default(uuid())
|
||||
legId String @map("leg_id")
|
||||
locationId String? @map("location_id")
|
||||
category ExpenseCategory
|
||||
amount Decimal @db.Decimal(15, 2)
|
||||
description String? @db.Text
|
||||
note String? @db.Text
|
||||
paidById String? @map("paid_by_id")
|
||||
|
||||
leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade)
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||
paidBy User? @relation("ExpensePaidBy", fields: [paidById], references: [id], onDelete: SetNull)
|
||||
}
|
||||
|
||||
model Photo {
|
||||
id String @id @default(uuid())
|
||||
tourId String
|
||||
locationId String?
|
||||
uploaderId String
|
||||
imageUrl String
|
||||
capturedAt DateTime @default(now())
|
||||
metadata Json?
|
||||
privacy PrivacyLevel @default(TOUR_ONLY)
|
||||
|
||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||
uploader User @relation(fields: [uploaderId], references: [id])
|
||||
}
|
||||
+13
-16
@@ -7,22 +7,21 @@ export class TourRoleGuard implements CanActivate {
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user; // Giả sử đã qua AuthGuard (Passport/JWT)
|
||||
const user = request.user;
|
||||
|
||||
// Lấy tourId từ URL params (:id hoặc :tourId)
|
||||
const tourId = parseInt(request.params.id || request.params.tourId);
|
||||
const tourId = request.params.id || request.params.tourId;
|
||||
const path = request.url;
|
||||
|
||||
if (!user || isNaN(tourId)) {
|
||||
if (!user || !tourId) {
|
||||
throw new ForbiddenException("Thông tin xác thực hoặc mã Tour không hợp lệ.");
|
||||
}
|
||||
|
||||
/**
|
||||
* TỐI ƯU: Chỉ truy vấn Database 1 lần duy nhất để lấy thông tin thành viên.
|
||||
* Chúng ta lưu kết quả vào request object để các interceptor hoặc controller
|
||||
* sau này có thể dùng lại mà không cần query lại.
|
||||
*/
|
||||
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: {
|
||||
tourId_userId: {
|
||||
tourId: tourId,
|
||||
@@ -31,20 +30,18 @@ export class TourRoleGuard implements CanActivate {
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
if (!participation) {
|
||||
throw new ForbiddenException("Bạn không phải là thành viên của tour này.");
|
||||
}
|
||||
|
||||
// Gắn thông tin vào request để sử dụng ở tầng Controller
|
||||
request.tourMembership = membership;
|
||||
request.tourParticipation = participation;
|
||||
|
||||
const role = membership.role;
|
||||
const role = participation.role;
|
||||
const isPlanPath = path.includes('/plans');
|
||||
const isExpensePath = path.includes('/expenses');
|
||||
|
||||
// 1. Chặn MEMBER_PHOTO_ONLY và VIEWER_EXTERNAL truy cập Plans & Expenses
|
||||
if (
|
||||
(role === 'MEMBER_PHOTO_ONLY' || role === 'VIEWER_EXTERNAL') &&
|
||||
(role === 'MEMBER_NO_FINANCE' || role === 'VIEWER_ONLY') &&
|
||||
(isPlanPath || isExpensePath)
|
||||
) {
|
||||
throw new ForbiddenException("Bạn không có quyền xem kế hoạch và chi phí của tour này.");
|
||||
|
||||
-154
@@ -1,154 +0,0 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
// --- Enums ---
|
||||
|
||||
enum MemberRole {
|
||||
OWNER
|
||||
EDITOR
|
||||
MEMBER_PLAN_ONLY
|
||||
MEMBER_PHOTO_ONLY
|
||||
VIEWER_EXTERNAL
|
||||
}
|
||||
|
||||
enum ExpenseCategory {
|
||||
LODGING
|
||||
DINING
|
||||
TRANSPORT
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum TriggerType {
|
||||
AUTO_BY_TIME
|
||||
MANUAL_BY_USER
|
||||
}
|
||||
|
||||
enum PrivacyLevel {
|
||||
PUBLIC_IN_TOUR
|
||||
PRIVATE_OWNER
|
||||
}
|
||||
|
||||
// --- Models ---
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
name String?
|
||||
avatar String?
|
||||
createdAt DateTime @default(now())
|
||||
isAdmin Boolean @default(false)
|
||||
isBlocked Boolean @default(false)
|
||||
|
||||
createdTours Tour[] @relation("TourCreator")
|
||||
tourMemberships TourMember[]
|
||||
uploadedPhotos Photo[]
|
||||
}
|
||||
|
||||
model Tour {
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
startDate DateTime?
|
||||
endDate DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
creatorId Int
|
||||
creator User @relation("TourCreator", fields: [creatorId], references: [id])
|
||||
|
||||
members TourMember[]
|
||||
legs Leg[]
|
||||
tasks Task[]
|
||||
photos Photo[]
|
||||
}
|
||||
|
||||
model TourMember {
|
||||
tourId Int
|
||||
userId Int
|
||||
role MemberRole @default(MEMBER_PLAN_ONLY)
|
||||
|
||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([tourId, userId])
|
||||
}
|
||||
|
||||
model Leg {
|
||||
id Int @id @default(autoincrement())
|
||||
tourId Int
|
||||
sequenceNumber Int
|
||||
notes String? @db.Text
|
||||
|
||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||
places Place[]
|
||||
expenses Expense[]
|
||||
tasks Task[]
|
||||
}
|
||||
|
||||
model Place {
|
||||
id Int @id @default(autoincrement())
|
||||
legId Int
|
||||
name String
|
||||
address String?
|
||||
|
||||
// Sử dụng Float cho Lat/Lng để dễ thao tác ở Frontend.
|
||||
// Nếu bạn muốn dùng PostGIS chuyên sâu, có thể dùng Unsupported("geography(Point, 4326)")
|
||||
latitude Float
|
||||
longitude Float
|
||||
|
||||
sequenceInLeg Int
|
||||
arrivalTime DateTime?
|
||||
departureTime DateTime?
|
||||
|
||||
leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade)
|
||||
expenses Expense[]
|
||||
photos Photo[]
|
||||
}
|
||||
|
||||
model Expense {
|
||||
id Int @id @default(autoincrement())
|
||||
legId Int
|
||||
placeId Int?
|
||||
category ExpenseCategory
|
||||
amount Decimal @db.Decimal(15, 2)
|
||||
currency String @default("VND")
|
||||
description String? @db.Text
|
||||
|
||||
leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade)
|
||||
place Place? @relation(fields: [placeId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Task {
|
||||
id Int @id @default(autoincrement())
|
||||
tourId Int
|
||||
legId Int?
|
||||
title String
|
||||
plannedTimestamp DateTime
|
||||
isCompleted Boolean @default(false)
|
||||
completedAt DateTime?
|
||||
triggerType TriggerType @default(MANUAL_BY_USER)
|
||||
|
||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||
leg Leg? @relation(fields: [legId], references: [id])
|
||||
}
|
||||
|
||||
model Photo {
|
||||
id Int @id @default(autoincrement())
|
||||
tourId Int
|
||||
placeId Int?
|
||||
uploaderId Int
|
||||
imageUrl String
|
||||
capturedAt DateTime @default(now())
|
||||
privacyLevel PrivacyLevel @default(PUBLIC_IN_TOUR)
|
||||
|
||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||
place Place? @relation(fields: [placeId], references: [id], onDelete: SetNull)
|
||||
uploader User @relation(fields: [uploaderId], references: [id])
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { Pool } from 'pg';
|
||||
import 'dotenv/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const adapter = new PrismaPg(pool);
|
||||
@@ -20,7 +21,9 @@ async function main() {
|
||||
data: {
|
||||
email: 'owner@travel.com',
|
||||
name: 'Lộc Phạm (Chủ Tour)',
|
||||
passwordHash: 'hashed_password_123',
|
||||
// Hash mật khẩu '123456' để có thể đăng nhập thực tế
|
||||
passwordHash: await bcrypt.hash('123456', 10),
|
||||
isAdmin: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -28,7 +31,7 @@ async function main() {
|
||||
data: {
|
||||
email: 'photomember@travel.com',
|
||||
name: 'Nguyễn Văn Ảnh (Chỉ xem ảnh)',
|
||||
passwordHash: 'hashed_password_456',
|
||||
passwordHash: await bcrypt.hash('123456', 10),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -38,11 +41,11 @@ async function main() {
|
||||
title: 'Hành trình khám phá TP.HCM',
|
||||
startDate: new Date('2023-11-20'),
|
||||
endDate: new Date('2023-11-21'),
|
||||
creatorId: owner.id,
|
||||
members: {
|
||||
createdById: owner.id,
|
||||
participants: {
|
||||
create: [
|
||||
{ userId: owner.id, role: 'OWNER' },
|
||||
{ userId: photoMember.id, role: 'MEMBER_PHOTO_ONLY' },
|
||||
{ userId: photoMember.id, role: 'VIEWER_ONLY' },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -52,25 +55,23 @@ async function main() {
|
||||
const leg1 = await prisma.leg.create({
|
||||
data: {
|
||||
tourId: tour.id,
|
||||
sequenceNumber: 1,
|
||||
notes: 'Khám phá lịch sử trung tâm',
|
||||
places: {
|
||||
sequence: 1,
|
||||
note: 'Khám phá lịch sử trung tâm',
|
||||
locations: {
|
||||
create: [
|
||||
{
|
||||
name: 'Dinh Độc Lập',
|
||||
address: '135 Nam Kỳ Khởi Nghĩa, Quận 1',
|
||||
latitude: 10.777,
|
||||
longitude: 106.695,
|
||||
sequenceInLeg: 1,
|
||||
arrivalTime: new Date('2023-11-20T08:00:00Z'),
|
||||
plannedStart: new Date('2023-11-20T08:00:00Z'),
|
||||
},
|
||||
{
|
||||
name: 'Bưu điện Thành phố',
|
||||
address: '02 Công xã Paris, Quận 1',
|
||||
latitude: 10.779,
|
||||
longitude: 106.699,
|
||||
sequenceInLeg: 2,
|
||||
arrivalTime: new Date('2023-11-20T10:00:00Z'),
|
||||
plannedStart: new Date('2023-11-20T10:00:00Z'),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -78,13 +79,14 @@ async function main() {
|
||||
});
|
||||
|
||||
console.log('--- Đang tạo chi phí mẫu... ---');
|
||||
await prisma.expense.create({
|
||||
const expense1 = await prisma.expense.create({
|
||||
data: {
|
||||
legId: leg1.id,
|
||||
category: 'DINING',
|
||||
category: 'FOOD',
|
||||
amount: 500000,
|
||||
currency: 'VND',
|
||||
description: 'Ăn trưa đặc sản Quận 1',
|
||||
note: 'Đặt trước cho 3 người',
|
||||
paidById: owner.id,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+344
-12
@@ -5,11 +5,33 @@ interface TourState {
|
||||
legs: any[];
|
||||
publicTours: any[];
|
||||
userRole: string | null;
|
||||
activeLegId: string | null;
|
||||
mapCenter: [number, number];
|
||||
setTour: (tour: any) => void;
|
||||
updateLegs: (legs: any[]) => void;
|
||||
optimizeRouting: () => Promise<void>;
|
||||
fetchTour: (id: number) => Promise<void>;
|
||||
createTour: (tourData: any) => Promise<any>;
|
||||
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 const useTourStore = create<TourState>((set, get) => ({
|
||||
@@ -17,23 +39,333 @@ export const useTourStore = create<TourState>((set, get) => ({
|
||||
legs: [],
|
||||
publicTours: [],
|
||||
userRole: null,
|
||||
activeLegId: null,
|
||||
mapCenter: [10.7769, 106.7009],
|
||||
setTour: (tour) => set({ currentTour: tour }),
|
||||
updateLegs: (legs) => set({ legs }),
|
||||
fetchTour: async (id: number) => {
|
||||
// Giả sử user hiện tại có ID là 1 (Lộc Phạm - OWNER) để test
|
||||
const currentUserId = 1;
|
||||
const response = await fetch(`http://localhost:3001/api/v1/tours/${id}`);
|
||||
setActiveLegId: (id) => set({ activeLegId: id }),
|
||||
setMapCenter: (pos) => set({ mapCenter: pos }),
|
||||
fetchTour: async (id: string) => {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
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 role = data.members?.find((m: any) => m.userId === currentUserId)?.role || 'VIEWER_EXTERNAL';
|
||||
set({ currentTour: data, legs: data.legs || [], userRole: role });
|
||||
// ...existing role detection...
|
||||
let role = 'VIEWER_ONLY';
|
||||
if (token) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const currentUserId = payload.sub;
|
||||
const participant = data.participants?.find((p: any) => 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: any) {
|
||||
console.error('Không thể tải tour:', err);
|
||||
// Không set null để tránh flash trắng; giữ nguyên state cũ nếu có
|
||||
}
|
||||
},
|
||||
fetchPublicTours: async () => {
|
||||
const response = await fetch(`http://localhost:3001/api/v1/tours/explore`);
|
||||
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 });
|
||||
},
|
||||
optimizeRouting: async () => {
|
||||
// Logic gọi API /api/v1/routing/optimize và cập nhật lại state
|
||||
createTour: async (tourData: any) => {
|
||||
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: string, data: any) => {
|
||||
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');
|
||||
|
||||
// Làm mới danh sách khám phá
|
||||
get().fetchPublicTours();
|
||||
},
|
||||
deleteTour: async (id: string) => {
|
||||
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');
|
||||
}
|
||||
|
||||
// Reset tour hiện tại nếu đang xem đúng tour vừa xóa
|
||||
if (get().currentTour?.id === id) set({ currentTour: null });
|
||||
get().fetchPublicTours();
|
||||
},
|
||||
addLeg: async (tourId: string, data: any) => {
|
||||
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: string, count: number) => {
|
||||
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: string, data: any) => {
|
||||
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: string) => {
|
||||
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: string, locationData: any) => {
|
||||
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');
|
||||
// Làm mới dữ liệu tour hiện tại
|
||||
get().fetchTour(tourId);
|
||||
},
|
||||
updateLocation: async (locationId: string, data: any) => {
|
||||
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: string) => {
|
||||
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: string, data: any) => {
|
||||
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: string, data: any) => {
|
||||
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: string) => {
|
||||
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: any) =>
|
||||
l.id === legId ? { ...l, locations: locations, totalDistance: totalDistance } : l
|
||||
);
|
||||
set({ currentTour: { ...currentTour, legs: updatedLegs }, legs: updatedLegs });
|
||||
}
|
||||
},
|
||||
removeMember: async (tourId: string, userId: string) => {
|
||||
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: string, member: { userId: string; role?: string }) => {
|
||||
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: string, userId?: string) => {
|
||||
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: string) => {
|
||||
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: string, requestId: string) => {
|
||||
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: string, requestId: string) => {
|
||||
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);
|
||||
},
|
||||
}));
|
||||
+1
-1
@@ -4,7 +4,7 @@ import react from '@vitejs/plugin-react';
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3002,
|
||||
port: 3002, // Thay đổi cổng thành 5173 (mặc định của Vite) hoặc cổng khác bạn muốn
|
||||
strictPort: true,
|
||||
host: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user