Compare commits
21 Commits
7fc80a49d0
...
4bec095a40
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bec095a40 | |||
| 81e715ddb3 | |||
| c6d0fdd5d1 | |||
| 3a29f98c96 | |||
| 71007bd9a1 | |||
| 65a9b39966 | |||
| 58087a4b37 | |||
| 838aec562f | |||
| ec650fb45d | |||
| bf63510980 | |||
| b3519c94cd | |||
| 5cfb5b9d15 | |||
| 4545bde8ec | |||
| 14bf43487e | |||
| ed437cdb0f | |||
| c7db2c3ed8 | |||
| 5e3f10631f | |||
| 578b03d808 | |||
| 25fcd5d926 | |||
| c51ddc34c7 | |||
| b54707823c |
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"continue.enableConsole": true
|
||||||
|
}
|
||||||
+374
-34
@@ -4,7 +4,125 @@ Tài liệu này mô tả kiến trúc tổng thể, mô hình dữ liệu và c
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Kiến Trúc Tổng Quan (System Overview)
|
## 8. Cấu Trúc Thư Mục (Directory Structure)
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/locpham/travelplanning/
|
||||||
|
├── admin.guard.ts # Middleware kiểm tra quyền admin
|
||||||
|
├── App.tsx # Component chính của ứng dụng frontend
|
||||||
|
├── ARCHITECTURE.md # Tài liệu kiến trúc hệ thống
|
||||||
|
├── CreateTourModal.tsx # Modal cho tạo tour
|
||||||
|
├── dist/ # Thư mục build output
|
||||||
|
├── .env # Biến môi trường
|
||||||
|
├── ExpenseManager.tsx # Component quản lý chi phí
|
||||||
|
├── ExploreMap.tsx # Component bản đồ khám phá
|
||||||
|
├── index.tsx # Điểm vào ứng dụng frontend
|
||||||
|
├── ItineraryTimeline.tsx # Component timeline lịch trình
|
||||||
|
├── jwt-auth.guard.ts # Middleware xác thực JWT
|
||||||
|
├── jwt.strategy.ts # Strategy xử lý JWT
|
||||||
|
├── LandingPage.tsx # Trang landing page
|
||||||
|
├── LoginModal.tsx # Modal đăng nhập
|
||||||
|
├── main.ts # Điểm vào ứng dụng backend
|
||||||
|
├── migrations/ # Migration cũ của ORM khác
|
||||||
|
│ ├── 20260613030135_init_travel_planning_schema/
|
||||||
|
│ │ └── migration.sql
|
||||||
|
│ ├── 20260613091754_add_is_admin_field/
|
||||||
|
│ │ └── migration.sql
|
||||||
|
│ ├── 20260613112158/
|
||||||
|
│ │ └── migration.sql
|
||||||
|
│ ├── 20260613114646/
|
||||||
|
│ │ └── migration.sql
|
||||||
|
│ └── migration_lock.toml
|
||||||
|
├── nest-cli.json # Cấu hình NestJS CLI
|
||||||
|
├── node_modules/ # Thư mục dependencies
|
||||||
|
├── package.json # Cấu hình project Node.js
|
||||||
|
├── package-lock.json # Lockfile dependencies
|
||||||
|
├── postcss.config.js # Cấu hình PostCSS
|
||||||
|
├── prisma/ # Thư mục Prisma ORM
|
||||||
|
│ ├── migrations/
|
||||||
|
│ │ └── 0_init/
|
||||||
|
│ │ └── migration.sql
|
||||||
|
│ ├── schema.prisma # Schema dữ liệu chính
|
||||||
|
│ └── prisma.service.ts # Service Prisma
|
||||||
|
├── rbac.middleware.ts # Middleware phân quyền RBAC
|
||||||
|
├── README.md # Tài liệu hướng dẫn dự án
|
||||||
|
├── schema.sql # File SQL schema
|
||||||
|
├── seed.ts # File seed dữ liệu mẫu
|
||||||
|
├── SignupPage.tsx # Trang đăng ký tài khoản
|
||||||
|
├── tailwind.config.ts # Cấu hình TailwindCSS
|
||||||
|
├── TourDetailPage.tsx # Trang chi tiết tour
|
||||||
|
├── tsconfig.build.json # Cấu hình TypeScript build
|
||||||
|
├── tsconfig.json # Cấu hình TypeScript
|
||||||
|
├── UITourDesign.md # Tài liệu thiết kế UI
|
||||||
|
├── useTourStore.ts # State management Zustand cho Tour
|
||||||
|
├── vite.config.ts # Cấu hình Vite bundler
|
||||||
|
├── .gitignore # Git ignore rules
|
||||||
|
├── .vscode/
|
||||||
|
│ └── settings.json # Cấu hình VS Code
|
||||||
|
└── index.css # Styles CSS toàn cục
|
||||||
|
```
|
||||||
|
|
||||||
|
### Giải thích Cấu trúc Thư mục
|
||||||
|
|
||||||
|
**Frontend Layer:**
|
||||||
|
- Các file `.tsx` (React components) xử lý giao diện người dùng: `LandingPage`, `TourDetailPage`, `CreateTourModal`, `ExpenseManager`, `ItineraryTimeline`, `ExploreMap`, `UserManagementModal`, `SignupPage`, `LoginModal`, `AddLocationModal`
|
||||||
|
- Cấu hình TailwindCSS (`tailwind.config.ts`, `index.css`) cho styling
|
||||||
|
- `useTourStore.ts` - State management với Zustand
|
||||||
|
|
||||||
|
**Backend Layer:**
|
||||||
|
- `main.ts` - Entry point ứng dụng NestJS/Express
|
||||||
|
- `expense-engine.service.ts` - Service tính toán và chia chi phí
|
||||||
|
- `prisma.service.ts` - Service kết nối cơ sở dữ liệu
|
||||||
|
- Guards & Middleware: `admin.guard.ts`, `jwt-auth.guard.ts`, `rbac.middleware.ts`
|
||||||
|
- `jwt.strategy.ts` - Xác thực người dùng qua JWT
|
||||||
|
|
||||||
|
**Database Layer:**
|
||||||
|
- `prisma/schema.prisma` - Định nghĩa schema dữ liệu
|
||||||
|
- `prisma/migrations/` - Lịch sử thay đổi schema của Prisma
|
||||||
|
- `migrations/` - Migration cũ (không dùng Prisma)
|
||||||
|
- `seed.ts` - Chèn dữ liệu mẫu vào cơ sở dữ liệu
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
- `tsconfig.json`, `tsconfig.build.json` - Cấu hình TypeScript
|
||||||
|
- `vite.config.ts` - Config bundler Vite
|
||||||
|
- `nest-cli.json` - Cấu hình NestJS CLI
|
||||||
|
- `postcss.config.js` - Cấu hình PostCSS cho TailwindCSS
|
||||||
|
- `.env` - Biến môi trường ứng dụng
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
- `ARCHITECTURE.md` - Tài liệu kiến trúc hệ thống
|
||||||
|
- `UITourDesign.md` - Tài liệu thiết kế giao diện người dùng
|
||||||
|
- `README.md` - Hướng dẫn cài đặt và chạy dự án
|
||||||
|
|
||||||
|
**Tools & IDE:**
|
||||||
|
- `.vscode/settings.json` - Cấu hình VS Code workspace
|
||||||
|
- `.gitignore` - Quy tắc bỏ qua file Git
|
||||||
|
|
||||||
|
### Công nghệ trong Dự án
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"frontend": {
|
||||||
|
"framework": "React + Vite",
|
||||||
|
"styling": "TailwindCSS + PostCSS",
|
||||||
|
"state_management": "Zustand",
|
||||||
|
"map_engine": "Yêu cầu tích hợp Leaflet/Google Maps API"
|
||||||
|
},
|
||||||
|
"backend": {
|
||||||
|
"framework": "NestJS/Express với TypeScript",
|
||||||
|
"auth": "JWT Strategy + Guards",
|
||||||
|
"orm": "Prisma",
|
||||||
|
"database": "PostgreSQL + PostGIS"
|
||||||
|
},
|
||||||
|
"build_tools": {
|
||||||
|
"bundler": "Vite",
|
||||||
|
"compiler": "TypeScript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
Hệ thống được thiết kế theo mô hình **Client-Server** kết hợp với kiến trúc **Modular Monolith** hoặc **Microservices** (tùy quy mô triển khai), chia làm 3 tầng chính:
|
Hệ thống được thiết kế theo mô hình **Client-Server** kết hợp với kiến trúc **Modular Monolith** hoặc **Microservices** (tùy quy mô triển khai), chia làm 3 tầng chính:
|
||||||
|
|
||||||
@@ -31,44 +149,38 @@ Hệ thống được thiết kế theo mô hình **Client-Server** kết hợp
|
|||||||
|
|
||||||
Dưới đây là các thực thể cốt lõi phục vụ tính năng:
|
Dưới đây là các thực thể cốt lõi phục vụ tính năng:
|
||||||
|
|
||||||
### 3.1. Users & Authentication
|
### 3.1. User (Người dùng)
|
||||||
* `users`: Lưu thông tin định danh (`id`, `email`, `password_hash`, `name`, `avatar`).
|
Lưu trữ thông tin định danh và trạng thái quản trị. Toàn bộ ID sử dụng định dạng **UUID**.
|
||||||
|
* `User`: `id`, `email`, `passwordHash`, `name`, `avatar`, `isAdmin`, `isBlocked`, `createdAt`.
|
||||||
|
|
||||||
### 3.2. Tour & Members (Quản lý đa người dùng & Phân quyền)
|
|
||||||
* `tours`: Thông tin tổng quan về chuyến đi.
|
|
||||||
* `id` (PK), `title`, `start_date`, `end_date`, `creator_id` (FK), `created_at`.
|
|
||||||
* `tour_members`: Bảng trung gian quản lý thành viên và quyền hạn (Bảo mật thông tin).
|
|
||||||
* `tour_id` (FK), `user_id` (FK).
|
|
||||||
* `role`: Định nghĩa các quyền cụ thể:
|
|
||||||
* `OWNER`: Toàn quyền (Người tạo).
|
|
||||||
* `EDITOR`: Sửa đổi kế hoạch, chi phí, xem/thêm ảnh.
|
|
||||||
* `MEMBER_PLAN_ONLY`: Chỉ xem/sửa kế hoạch, không xem được chi phí.
|
|
||||||
* `MEMBER_PHOTO_ONLY`: Chỉ được xem/đăng ảnh trong album, không thấy kế hoạch và chi phí.
|
|
||||||
* `VIEWER_EXTERNAL`: Người ngoài được share link, chỉ xem được ảnh công khai (tùy thuộc vào cài đặt privacy).
|
|
||||||
|
|
||||||
### 3.3. Itinerary & Map (Chặng & Địa điểm)
|
### 3.2. Tour & Phân quyền (RBAC)
|
||||||
* `legs` (Chặng): Một tour có nhiều chặng.
|
Hệ thống sử dụng bảng trung gian để quản lý thành viên cho từng chuyến đi.
|
||||||
* `id` (PK), `tour_id` (FK), `sequence_number` (Thứ tự chặng: 1, 2, 3...), `notes`.
|
* `Tour`: `id`, `title`, `startDate`, `endDate`, `totalCost` (Decimal), `createdById` (FK -> User).
|
||||||
* `places` (Địa điểm trong chặng):
|
* `TourParticipant`: Quản lý vai trò thành viên trong Tour qua enum `ParticipantRole`:
|
||||||
* `id` (PK), `leg_id` (FK), `name`, `address`, `latitude`, `longitude` (Dữ liệu PostGIS), `sequence_in_leg`.
|
* `OWNER`: Toàn quyền quản lý.
|
||||||
* `arrival_time` (Dự kiến), `departure_time` (Dự kiến).
|
* `MANAGER`: Quản lý nội dung và thành viên.
|
||||||
|
* `MEMBER`: Thành viên chính thức (Xem được chi phí).
|
||||||
|
* `MEMBER_NO_FINANCE`: Thành viên không được xem thông tin tài chính.
|
||||||
|
* `VIEWER_ONLY`: Chỉ xem lộ trình và ảnh.
|
||||||
|
|
||||||
### 3.4. Expenses (Chi phí)
|
### 3.3. Lộ trình (Leg & Location)
|
||||||
* `expenses`: Lưu vết chi phí cho từng địa điểm/chặng.
|
Một Tour được chia thành nhiều chặng di chuyển (Leg), mỗi chặng chứa danh sách các điểm đến (Location).
|
||||||
* `id` (PK), `leg_id` (FK), `place_id` (FK, nullable), `category` (`LODGING`, `DINING`, `TRANSPORT`, `OTHER`), `amount` (Số tiền), `currency`, `description`.
|
* `Leg`: `id`, `tourId` (FK), `sequence` (Thứ tự chặng), `note`.
|
||||||
* `tour_cost_summaries`: Cấu hình phân chia tiền ở cuối tour/chặng.
|
* `Location`: Tích hợp tính năng theo dõi tiến độ (Thay thế khái niệm `Task` cũ).
|
||||||
* `tour_id` (FK), `total_cost`, `adult_count`, `child_count`, `child_discount_percent` (Ví dụ: trẻ em giảm 30%).
|
* Tọa độ: `latitude`, `longitude`.
|
||||||
|
* Thời gian: `plannedStart`, `plannedEnd`, `actualStart`, `actualEnd`.
|
||||||
|
* Trạng thái (`LocationStatus`): `PENDING` (Chờ), `COMPLETED` (Hoàn thành).
|
||||||
|
* Loại địa điểm (`LocationType`): `MOVE`, `VISIT`, `REST`, `EAT`.
|
||||||
|
|
||||||
### 3.5. Tasks & Timeline Tracking (Theo dõi tiến độ)
|
### 3.4. Chi phí (Expense)
|
||||||
* `tasks`: Các hoạt động cần tích chọn hoàn thành.
|
Quản lý tài chính cho từng chặng hoặc gắn trực tiếp vào một địa điểm cụ thể.
|
||||||
* `id` (PK), `tour_id` (FK), `leg_id` (FK), `title`, `planned_timestamp`.
|
* `Expense`: `id` (UUID, PK), `leg_id` (UUID, FK), `location_id` (UUID, FK, Nullable), `category` (Enum: `ACCOMMODATION`, `FOOD`, `TRANSPORT`, `TICKET`, `OTHER`), `amount` (Decimal), `description` (Text).
|
||||||
* `is_completed` (Boolean).
|
|
||||||
* `completed_at` (Timestamp - Ghi lại lúc hệ thống tự động tích hoặc user chủ động tích).
|
|
||||||
* `trigger_type` (`AUTO_BY_TIME` hoặc `MANUAL_BY_USER`).
|
|
||||||
|
|
||||||
### 3.6. Album Ảnh (Tách biệt logic kế hoạch)
|
### 3.5. Album Ảnh (Photo)
|
||||||
* `photos`: Lưu trữ hình ảnh của tour gắn với địa điểm.
|
Lưu trữ tài nguyên đa phương tiện gắn với bối cảnh chuyến đi.
|
||||||
* `id` (PK), `tour_id` (FK), `place_id` (FK, nullable), `uploader_id` (FK), `image_url` (Đường dẫn S3), `captured_at` (Metadata từ ảnh hoặc thời gian tạo), `privacy_level` (`PUBLIC_IN_TOUR`, `PRIVATE_OWNER`).
|
* `Photo`: `id`, `tourId` (FK), `locationId` (FK, nullable), `uploaderId` (FK), `imageUrl`, `privacy`.
|
||||||
|
* Cấp độ bảo mật (`PrivacyLevel`): `PUBLIC`, `TOUR_ONLY`, `PRIVATE`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -118,3 +230,231 @@ Dưới đây là các thực thể cốt lõi phục vụ tính năng:
|
|||||||
3. Khi một **User khác** truy cập link Tour:
|
3. Khi một **User khác** truy cập link Tour:
|
||||||
* Hệ thống check `tour_members`.
|
* Hệ thống check `tour_members`.
|
||||||
* Nếu thuộc diện *External Viewer* hoặc *Photo Only*: Ẩn hoàn toàn tab "Kế hoạch chặng hành trình" và "Tổng số tiền", chỉ hiển thị giao diện Grid hình ảnh (`album`).
|
* Nếu thuộc diện *External Viewer* hoặc *Photo Only*: Ẩn hoàn toàn tab "Kế hoạch chặng hành trình" và "Tổng số tiền", chỉ hiển thị giao diện Grid hình ảnh (`album`).
|
||||||
|
|
||||||
|
# 6. THIẾT KẾ GIAO DIỆN NGƯỜI DÙNG (TOUR DASHBOARD & MAP ITINERARY)
|
||||||
|
|
||||||
|
Đây là màn hình Hub chính sau khi người dùng truy cập vào một Tour cụ thể. Giao diện được thiết kế để tối ưu hóa trải nghiệm trên thiết bị di động (Mobile-first) và kiểm soát hiển thị nội dung động dựa trên vai trò của thành viên (`ParticipantRole`).
|
||||||
|
|
||||||
|
## 6.1. Thành phần Giao diện chính
|
||||||
|
|
||||||
|
* **Top Banner (Khu vực tiêu đề):**
|
||||||
|
* **Cover Image:** Ảnh bìa của tour (lấy từ ảnh đầu tiên trong Album hoặc ảnh phong cảnh mặc định).
|
||||||
|
* **Thông tin Tour:** Hiển thị tên Tour và khoảng thời gian diễn ra (`startDate` -> `endDate`).
|
||||||
|
* **Member Avatars:** Danh sách avatar các thành viên tham gia (`TourParticipant`). Cho phép nhấn vào để xem chi tiết hoặc mời thêm người (nếu có quyền).
|
||||||
|
|
||||||
|
|
||||||
|
* **Financial Quick-View Widget (Khối tài chính nhanh):**
|
||||||
|
* Hiển thị `totalCost` hiện tại của toàn bộ chuyến đi.
|
||||||
|
* **Logic Ẩn/Hiện động (RBAC):**
|
||||||
|
* *Hiển thị:* Với các role `OWNER`, `MANAGER`, `MEMBER`.
|
||||||
|
* *Ẩn:* Với các role `MEMBER_NO_FINANCE` hoặc `VIEWER_ONLY`. Thay thế khối này bằng một câu quote truyền cảm hứng du lịch (Ví dụ: *"Đừng nghe họ nói, hãy tự mình đi xem"*).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
* **Bottom Navigation Bar hoặc Tab View:** Hệ thống chia làm 4 tab chính để tránh nhồi nhét dữ liệu:
|
||||||
|
1. **Lộ trình (Itinerary):** Hiển thị danh sách `Leg` & `Location` tích hợp Bản đồ tương tác. *Đây là tab mặc định.*
|
||||||
|
2. **Chi phí (Finance):** Quản lý các `Expense`. Chỉ hiển thị cho các role có quyền truy cập tài chính.
|
||||||
|
3. **Album ảnh (Gallery):** Hiển thị lưới hình ảnh `Photo` được chia sẻ trong chuyến đi.
|
||||||
|
4. **Thành viên & Cài đặt (Settings):** Khu vực dành riêng cho `OWNER` hoặc `MANAGER` để quản lý thành viên, phân quyền và cấu hình tour.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.2. Sơ đồ bố cục tổng thể (Wireframe mô phỏng)
|
||||||
|
|
||||||
|
```text
|
||||||
|
+---------------------------------------+
|
||||||
|
| [ < ] Tên Chuyến Đi [ . . ] | <-- Header bar
|
||||||
|
+---------------------------------------+
|
||||||
|
| |
|
||||||
|
| IMAGE COVER PHOTO | <-- Top Banner
|
||||||
|
| |
|
||||||
|
| [Avatar][Avatar][Avatar] +3 |
|
||||||
|
+---------------------------------------+
|
||||||
|
| |
|
||||||
|
| { TỔNG CHI PHÍ: 2.500.000 VND } | <-- Finance Widget (Hoặc Quote)
|
||||||
|
| |
|
||||||
|
+---------------------------------------+
|
||||||
|
| [ Lộ trình ] [ Chi phí ] [ Ảnh ] [S] | <-- Tab Navigation
|
||||||
|
+---------------------------------------+
|
||||||
|
| |
|
||||||
|
| TAB LỘ TRÌNH (BẢN ĐỒ + DANH SÁCH) |
|
||||||
|
| |
|
||||||
|
+---------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.3. Tab Lộ Trình: Bản Đồ Tương Tác & Cấu Trúc Chặng (Map-Driven Timeline)
|
||||||
|
|
||||||
|
Để tối ưu hóa UX trên di động, Tab Lộ trình được chia thành 2 chế độ hiển thị bằng một nút bấm chuyển đổi nhanh (Toggle Switch): **Chế độ Bản đồ (Map View)** và **Chế độ Danh sách (Timeline View)**. Khi chỉnh sửa/khởi tạo, Chế độ Bản đồ sẽ làm chủ đạo.
|
||||||
|
|
||||||
|
### A. Luồng Khởi Tạo & Định Vị Lộ Trình (Google Maps Style)
|
||||||
|
|
||||||
|
Đối với các tài khoản có quyền chỉnh sửa (`OWNER`, `MANAGER`), luồng thiết lập lộ trình không gian được thực hiện qua các bước tương tác trực quan:
|
||||||
|
|
||||||
|
#### Giai đoạn 1: Đóng khung điểm Đầu - Cuối
|
||||||
|
|
||||||
|
1. **Khai báo Tour:** User nhập thông tin cơ bản ở bảng cấu hình.
|
||||||
|
2. **Xác định Điểm Đầu:** Tìm kiếm trên ô Search của bản đồ hoặc rê chuột/di tâm màn hình đến vị trí mong muốn ➔ **Click chuột phải** (hoặc **Nhấn giữ** nếu dùng mobile) ➔ Chọn **"Bắt đầu từ đây"**.
|
||||||
|
* *Phản hồi UI:* Xuất hiện Marker màu Xanh lá `[S]`. Tạo bản ghi `Location` đầu tiên.
|
||||||
|
|
||||||
|
|
||||||
|
3. **Xác định Điểm Cuối:** Di chuyển đến điểm đích ➔ **Click chuột phải / Nhấn giữ** ➔ Chọn **"Kết thúc ở đây"**.
|
||||||
|
* *Phản hồi UI:* Xuất hiện Marker màu Đỏ `[E]`.
|
||||||
|
|
||||||
|
|
||||||
|
4. **Khai báo số lượng Chặng:** Một ô thông báo nổi (Pop-up) yêu cầu nhập: *"Chuyến đi này chia làm bao nhiêu chặng?"*. Khi nhập số $N$, hệ thống tự động sinh ra $N$ bản ghi `Leg` với `sequence` từ $1$ đến $N$ ở trạng thái chờ rỗng.
|
||||||
|
|
||||||
|
#### Giai đoạn 2: Ghim điểm tham quan vào Chặng (Map-to-Leg)
|
||||||
|
|
||||||
|
1. **Chấm điểm:** Người dùng gõ tìm kiếm tọa độ/địa danh hoặc click trực tiếp lên bản đồ.
|
||||||
|
2. **Phân bổ vào chặng:** Click chuột phải / Nhấn giữ vào điểm vừa chọn ➔ Menu ngữ cảnh hiện ra danh sách các chặng đã khai báo ở Giai đoạn 1 ➔ Chọn số thứ tự chặng (Ví dụ: `Thêm vào Chặng 1`).
|
||||||
|
3. **Tự động sắp xếp & Vẽ tuyến:** * Dữ liệu địa điểm lập tức được xếp vào mảng con của `Leg` có `sequence: 1`.
|
||||||
|
* Hệ thống tự động sắp xếp vị trí hiển thị theo thứ tự thêm vào (có thể kéo thả đổi thứ tự thủ công sau).
|
||||||
|
* Bản đồ tự động gọi API Routing để vẽ đường nối liền mạch: **`[S]` ➔ Các điểm Chặng 1 ➔ Các điểm Chặng 2 ➔ `[E]**`.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#### Giai đoạn 3: Tối ưu Lịch trình & Thời gian (Data Refinement)
|
||||||
|
|
||||||
|
Sau khi "bộ khung" không gian trên bản đồ đã hoàn tất, người dùng chuyển sang giao diện danh sách để cấu hình chi tiết tuyến tính thời gian:
|
||||||
|
|
||||||
|
* Bổ sung ngày giờ kế hoạch (`plannedStart`, `plannedEnd`) cho từng `Location`.
|
||||||
|
* Chọn loại địa điểm (`LocationType`: `MOVE`, `VISIT`, `REST`, `EAT`) để hệ thống đồng bộ Icon hiển thị.
|
||||||
|
* Thêm ghi chú (`note`) tổng quan cho từng `Leg`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
WIREFRAME CHẾ ĐỘ BẢN ĐỒ (MAP VIEW MODE):
|
||||||
|
+---------------------------------------+
|
||||||
|
| [ Tìm kiếm địa điểm trên bản đồ... ] |
|
||||||
|
+---------------------------------------+
|
||||||
|
| |
|
||||||
|
| [S] (Điểm xuất phát) |
|
||||||
|
| \ |
|
||||||
|
| \____ [Icon: EAT] Nhà hàng A |
|
||||||
|
| \ |
|
||||||
|
| \____ [E] (Điểm cuối) |
|
||||||
|
| |
|
||||||
|
| CONTEXT MENU KHI NHẤN GIỮ/CHUỘT PHẢI: |
|
||||||
|
| +---------------------------------+ |
|
||||||
|
| | o Bắt đầu từ đây | |
|
||||||
|
| | o Kết thúc ở đây | |
|
||||||
|
| | o Thêm vào lộ trình > [Chặng 1]| |
|
||||||
|
| +------------------------[Chặng 2]| |
|
||||||
|
+---------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B. Chế Độ Hiển Thị Timeline Đứng (Dành cho việc Theo Dõi Tiến Độ)
|
||||||
|
|
||||||
|
Sau khi lưu lộ trình, khi đi du lịch thực tế, người dùng sẽ chủ yếu xem ở giao diện **Timeline đứng** để kiểm tra tiến độ:
|
||||||
|
|
||||||
|
#### Tầng 1 - Chọn Chặng (Leg Picker)
|
||||||
|
|
||||||
|
* **UI Component:** Sử dụng **Horizontal Tab Scroller** đặt ở phía trên cùng của tab.
|
||||||
|
* **Chức năng:** Người dùng có thể chọn nhanh các Chặng (ví dụ: Chặng 1, Chặng 2, Chặng 3...) để lọc dữ liệu hiển thị, tránh kéo màn hình quá dài.
|
||||||
|
* **Logic:** Nhấn vào `Leg` nào, danh sách các `Location` được xếp theo đúng `sequence` của chặng đó mới hiển thị phía dưới.
|
||||||
|
|
||||||
|
#### Tầng 2 - Danh sách Địa điểm (Location Cards)
|
||||||
|
|
||||||
|
Mỗi địa điểm là một thẻ (card) nằm trên đường timeline đứng, bao gồm các đặc điểm nhận diện:
|
||||||
|
|
||||||
|
* **Icon nhận diện theo loại (LocationType):**
|
||||||
|
* `MOVE`: Icon Phương tiện (Xe bus, Máy bay).
|
||||||
|
* `VISIT`: Icon Tham quan (Kính thiên văn, Lá cờ).
|
||||||
|
* `REST`: Icon Nghỉ ngơi (Giường ngủ).
|
||||||
|
* `EAT`: Icon Ẩm thực (Dao dĩa).
|
||||||
|
|
||||||
|
|
||||||
|
* **Trạng thái & Tiến độ (LocationStatus):**
|
||||||
|
* **Trạng thái PENDING:** * *UI:* Card có viền nét đứt (dashed), màu xám nhạt. Hiển thị giờ dự kiến (`plannedStart`).
|
||||||
|
* *Hành động:* Nút "Bắt đầu" (Chỉ hiển thị cho vai trò `OWNER` hoặc `MANAGER`).
|
||||||
|
|
||||||
|
|
||||||
|
* **Trạng thái Đang diễn ra (Active):**
|
||||||
|
* *UI:* Card được làm sáng (highlight), hiển thị giờ bắt đầu thực tế (`actualStart`).
|
||||||
|
* *Hành động:* Nút "Hoàn thành".
|
||||||
|
|
||||||
|
|
||||||
|
* **Trạng thái COMPLETED:**
|
||||||
|
* *UI:* Card có dấu tích xanh, màu nền chuyển sang tone dịu (ví dụ: xanh lá nhạt), hiển thị giờ kết thúc thực tế (`actualEnd`).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.4. Tab Chi Phí (Expense Management)
|
||||||
|
|
||||||
|
Cấu trúc dữ liệu của hệ thống cho phép gắn `Expense` (Chi phí) vào một `Leg` (Chặng) hoặc gắn trực tiếp vào một `Location` (Địa điểm) cụ thể.
|
||||||
|
|
||||||
|
### A. Phân tích trực quan
|
||||||
|
|
||||||
|
* **Biểu đồ tròn (Donut Chart):** Hiển thị ở đầu tab để phân tích chi phí theo danh mục (`category`: FOOD, TRANSPORT, ACCOMMODATION, TICKET...). Giúp người dùng nhìn trực quan ngân sách đang đổ vào đâu nhiều nhất.
|
||||||
|
|
||||||
|
### B. Danh sách hiển thị (List View)
|
||||||
|
|
||||||
|
* **Hiển thị dòng tiền:** Liệt kê các khoản chi tiêu kèm mô tả (`description`) và số tiền chính xác (`amount`).
|
||||||
|
* **Nhãn bối cảnh (Context Tag):** Mỗi dòng chi phí có một nhãn nhỏ đi kèm giúp người dùng xác định bối cảnh chi tiêu:
|
||||||
|
* Nếu `location_id` có dữ liệu: Hiển thị nhãn **“Tại: [Tên địa điểm]”**.
|
||||||
|
* Nếu chỉ có `leg_id`: Hiển thị nhãn **“Thuộc: [Tên Chặng]”**.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### C. Nút thêm nhanh (Floating Action Button)
|
||||||
|
|
||||||
|
* **Nút +:** Nằm ở góc dưới màn hình để thêm mới chi phí.
|
||||||
|
* **Logic thông minh:** Khi nhấn vào, hệ thống sẽ tự động bắt bối cảnh (Context-aware). Nếu user đang đứng xem ở `Chặng 2`, form thêm mới sẽ tự động chọn sẵn `leg_id` của Chặng 2 để giảm thiểu các bước nhập liệu thủ công.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.5. Tab Album Ảnh (Contextual Photo Gallery)
|
||||||
|
|
||||||
|
Bảng `Photo` có trường `locationId` (nullable) và `privacy`. Giao diện cần dạng hóa việc lọc để tối ưu trải nghiệm xem lại kỷ niệm.
|
||||||
|
|
||||||
|
### A. Chế độ xem thông minh (Smart View)
|
||||||
|
|
||||||
|
Cung cấp 2 chế độ lọc thông qua nút gạt (Toggle Switch):
|
||||||
|
|
||||||
|
* **Xem theo dòng thời gian (Timeline Photo):** Ảnh được gom nhóm (`group by`) theo từng `Location`. Đi tới địa điểm nào trên bản đồ/timeline, ảnh chụp tại đó sẽ hiện ngay bên dưới địa điểm đó.
|
||||||
|
* **Xem dạng lưới (Grid View):** Hiển thị dạng lưới ảnh tiêu chuẩn (3x3 hoặc 4x4), tương tự các ứng dụng quản lý ảnh gốc trên điện thoại.
|
||||||
|
|
||||||
|
### B. Bộ lọc Quyền riêng tư (PrivacyLevel) & Chỉ báo UI
|
||||||
|
|
||||||
|
* Các ảnh có tag `PRIVATE` chỉ hiển thị duy nhất với chính người tải lên (`uploaderId`).
|
||||||
|
* Ảnh `TOUR_ONLY` hiển thị cho mọi thành viên có trong Tour.
|
||||||
|
* **Chỉ báo UI (Privacy Indicators):** Trên góc mỗi ảnh hiển thị một icon nhỏ (Hình ổ khóa cho `PRIVATE`, Hình con mắt cho `TOUR_ONLY` hoặc `PUBLIC`) giúp người dùng dễ dàng kiểm soát trạng thái chia sẻ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Tối ưu hóa State Management & Hiệu năng Frontend (Crucial)
|
||||||
|
|
||||||
|
Vì cấu trúc dữ liệu của dự án sử dụng rất nhiều ID nối nhau (Foreign Keys), để frontend chạy mượt và không bị giật lag khi render, các kỹ thuật sau được ưu tiên áp dụng:
|
||||||
|
|
||||||
|
### 7.1. Chuẩn hóa dữ liệu ở Frontend (Data Normalization)
|
||||||
|
Khi gọi API lấy chi tiết Tour, cấu trúc cây sẽ được biến đổi thành cấu trúc phẳng (Flatten State) bằng cách lưu dữ liệu dưới dạng Object Key-Value (Dùng ID làm Key).
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Ví dụ cấu trúc State tối ưu ở Frontend
|
||||||
|
const locationsState = {
|
||||||
|
"uuid-location-1": { latitude: 16.0, longitude: 108.0, status: "PENDING", ... },
|
||||||
|
"uuid-location-2": { ... }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
Khi một Location thay đổi trạng thái từ `PENDING` sang `COMPLETED`, hệ thống chỉ cần cập nhật đúng object đó thông qua ID, giúp các thành phần khác của UI không bị re-render vô ích, tối ưu hóa hiệu năng render của React.
|
||||||
|
|
||||||
|
### 7.2. Xử lý Real-time và Optimistic Updates (Cập nhật lạc quan)
|
||||||
|
* **Đồng bộ Real-time:** Sử dụng **Websocket** để đồng bộ trạng thái giữa các thành viên trong Tour. Khi một người bấm "Hoàn thành" địa điểm hoặc "Thêm chi phí", những người khác sẽ nhận được cập nhật ngay lập tức.
|
||||||
|
* **Optimistic Update:** Khi một thành viên thêm một `Expense`, Frontend sẽ lập tức cộng số tiền đó vào `totalCost` hiển thị trên màn hình trước khi nhận phản hồi từ server. Nếu API trả về lỗi, hệ thống sẽ thực hiện roll-back trạng thái dữ liệu. Điều này tạo cảm giác ứng dụng phản hồi tức thì.
|
||||||
|
|
||||||
|
### 7.3. Phân quyền UI động (Dynamic UI Rendering based on Role)
|
||||||
|
Sử dụng các hàm Helper tại Frontend để kiểm tra quyền hạn trước khi render các thành phần tương tác, đảm bảo tính bảo mật và trải nghiệm người dùng:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const canEdit = ['OWNER', 'MANAGER'].includes(userRole);
|
||||||
|
|
||||||
|
// Trên UI Component:
|
||||||
|
{canEdit && <Button onClick={handleEditLocation}>Chỉnh sửa lộ trình</Button>}
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { X, MapPin, Loader2, Clock, Map as MapIcon } from 'lucide-react';
|
||||||
|
import { useTourStore } from './useTourStore.js';
|
||||||
|
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
|
||||||
|
import L from 'leaflet';
|
||||||
|
|
||||||
|
// Cấu hình Icon mặc định để tránh crash Marker trong Modal
|
||||||
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||||
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||||
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Component Helper xử lý việc chọn vị trí trên mini map bằng chuột phải
|
||||||
|
const MapPicker = ({ onPick, center }: { onPick: (latlng: L.LatLng) => void, center: [number, number] }) => {
|
||||||
|
const [menuPos, setMenuPos] = useState<{ x: number, y: number, latlng: L.LatLng } | null>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
useMapEvents({
|
||||||
|
contextmenu: (e) => {
|
||||||
|
L.DomEvent.preventDefault(e.originalEvent);
|
||||||
|
L.DomEvent.stopPropagation(e.originalEvent);
|
||||||
|
setMenuPos({ x: e.containerPoint.x, y: e.containerPoint.y, latlng: e.latlng });
|
||||||
|
},
|
||||||
|
click: () => setMenuPos(null),
|
||||||
|
dragstart: () => setMenuPos(null),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (menuPos && menuRef.current) {
|
||||||
|
L.DomEvent.disableClickPropagation(menuRef.current);
|
||||||
|
}
|
||||||
|
}, [menuPos]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
map.setView(center, map.getZoom());
|
||||||
|
}, [center]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{menuPos && (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="absolute z-[3000] bg-white rounded-xl shadow-xl border border-gray-100 py-1 w-44 animate-in zoom-in-95 duration-200"
|
||||||
|
style={{ top: menuPos.y, left: menuPos.x }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onPick(menuPos.latlng); setMenuPos(null); }}
|
||||||
|
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-xs font-bold text-blue-600 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<MapPin className="w-3 h-3" /> Thêm vào chặng hiện tại
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any }) => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
address: '',
|
||||||
|
latitude: 10.7769,
|
||||||
|
longitude: 106.7009,
|
||||||
|
type: 'VISIT',
|
||||||
|
legId: '',
|
||||||
|
note: '',
|
||||||
|
expenseAmount: '',
|
||||||
|
expenseCategory: 'OTHER',
|
||||||
|
expenseDescription: '',
|
||||||
|
expenseNote: '',
|
||||||
|
paidById: '',
|
||||||
|
plannedStart: '',
|
||||||
|
plannedEnd: ''
|
||||||
|
});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Gom các selector lại để giảm số lượng Hook gọi nội bộ và tăng hiệu năng
|
||||||
|
const { legs, addLocation, updateLocation, mapCenter, currentTour } = useTourStore();
|
||||||
|
|
||||||
|
// 1. Luôn khai báo Hook ở cấp cao nhất, không đặt code logic/return giữa các Hook
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
if (editingLocation) {
|
||||||
|
const leg = legs.find(l => l.id === editingLocation.legId);
|
||||||
|
const expense = leg?.expenses?.find((e: any) => e.locationId === editingLocation.id);
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
name: editingLocation.name || '',
|
||||||
|
address: editingLocation.address || '',
|
||||||
|
latitude: editingLocation.latitude,
|
||||||
|
longitude: editingLocation.longitude,
|
||||||
|
type: editingLocation.type || 'VISIT',
|
||||||
|
legId: editingLocation.legId || '',
|
||||||
|
note: editingLocation.note || '',
|
||||||
|
expenseAmount: expense?.amount?.toString() || '',
|
||||||
|
expenseCategory: expense?.category || 'OTHER',
|
||||||
|
expenseDescription: expense?.description || '',
|
||||||
|
expenseNote: expense?.note || '',
|
||||||
|
paidById: expense?.paidById || '',
|
||||||
|
plannedStart: editingLocation.plannedStart ? editingLocation.plannedStart.slice(0, 16) : '',
|
||||||
|
plannedEnd: editingLocation.plannedEnd ? editingLocation.plannedEnd.slice(0, 16) : ''
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
name: '', address: '',
|
||||||
|
legId: initialLegId || (legs.length > 0 ? legs[0].id : ''),
|
||||||
|
note: '', expenseAmount: '', expenseCategory: 'OTHER',
|
||||||
|
expenseDescription: '', expenseNote: '', paidById: '',
|
||||||
|
plannedStart: '', plannedEnd: ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [initialLegId, editingLocation, isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && !formData.name) {
|
||||||
|
setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] }));
|
||||||
|
}
|
||||||
|
}, [isOpen, mapCenter]);
|
||||||
|
|
||||||
|
// 2. Thực hiện các tính toán và hàm xử lý
|
||||||
|
const currentLegId = formData.legId || (legs.length > 0 ? legs[0].id : '');
|
||||||
|
const targetLeg = legs.find(l => l.id === currentLegId);
|
||||||
|
const titleText = editingLocation ? `Sửa địa điểm: ${editingLocation.name}` : (initialLegId ? `Thêm địa điểm cho ${targetLeg?.note || `Chặng ${targetLeg?.sequence}`}` : 'Thêm địa điểm mới');
|
||||||
|
const buttonText = editingLocation ? 'Cập nhật thay đổi' : (initialLegId ? 'Xác nhận thêm vào chặng' : 'Thêm địa điểm');
|
||||||
|
|
||||||
|
const handlePickLocation = async (latlng: L.LatLng) => {
|
||||||
|
setFormData(prev => ({ ...prev, latitude: latlng.lat, longitude: latlng.lng }));
|
||||||
|
|
||||||
|
// Tự động lấy tên địa điểm từ tọa độ vừa chọn
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latlng.lat}&lon=${latlng.lng}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const addr = data.address;
|
||||||
|
const detectedName = addr.amenity || addr.building || addr.historic || addr.tourist ||
|
||||||
|
addr.shop || addr.office || addr.leisure || addr.attraction ||
|
||||||
|
addr.road || addr.neighbourhood || addr.suburb ||
|
||||||
|
data.display_name?.split(',')[0] || "";
|
||||||
|
|
||||||
|
setFormData(prev => ({ ...prev, name: detectedName || prev.name, address: data.display_name || prev.address }));
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Early return phải nằm SAU tất cả các khai báo Hook
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const payload: any = {
|
||||||
|
...formData,
|
||||||
|
legId: currentLegId,
|
||||||
|
latitude: parseFloat(formData.latitude as any),
|
||||||
|
longitude: parseFloat(formData.longitude as any),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingLocation) {
|
||||||
|
await updateLocation(editingLocation.id, payload);
|
||||||
|
} else {
|
||||||
|
await addLocation(tourId, payload);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Lỗi khi lưu địa điểm');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
|
||||||
|
<div className="relative w-full max-w-lg bg-white rounded-3xl shadow-2xl overflow-hidden p-8 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<MapIcon className="w-6 h-6 text-blue-600" /> {titleText}
|
||||||
|
</h2>
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||||
|
<X className="w-6 h-6 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mini Map Picker */}
|
||||||
|
<div className="h-48 w-full rounded-2xl overflow-hidden mb-6 border border-gray-100 relative shadow-inner group">
|
||||||
|
<MapContainer center={[formData.latitude, formData.longitude]} zoom={13} className="h-full w-full">
|
||||||
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||||
|
<Marker position={[formData.latitude, formData.longitude]} />
|
||||||
|
<MapPicker center={[formData.latitude, formData.longitude]} onPick={handlePickLocation} />
|
||||||
|
</MapContainer>
|
||||||
|
<div className="absolute bottom-2 left-2 z-[1000] bg-white/90 backdrop-blur-sm px-2 py-1 rounded-lg text-[10px] font-black text-gray-500 shadow-sm border border-gray-100">
|
||||||
|
CHUỘT PHẢI ĐỂ CHỌN VỊ TRÍ
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Tên địa điểm</label>
|
||||||
|
<input required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||||
|
value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Địa chỉ</label>
|
||||||
|
<input className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||||
|
value={formData.address} onChange={e => setFormData({...formData, address: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Ghi chú địa điểm / Dịch vụ sử dụng</label>
|
||||||
|
<textarea className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none resize-none" rows={2}
|
||||||
|
placeholder="Ví dụ: Ăn trưa tại quán X, thuê hướng dẫn viên..."
|
||||||
|
value={formData.note} onChange={e => setFormData({...formData, note: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50/60 border border-blue-100 rounded-2xl p-4 space-y-3">
|
||||||
|
<p className="text-xs font-black text-blue-500 uppercase tracking-widest">Chi phí nhanh tại điểm này</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-gray-600 mb-1">Số tiền (VNĐ)</label>
|
||||||
|
<input type="number" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||||
|
placeholder="0"
|
||||||
|
value={formData.expenseAmount} onChange={e => setFormData({...formData, expenseAmount: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-gray-600 mb-1">Loại dịch vụ</label>
|
||||||
|
<select className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||||
|
value={formData.expenseCategory} onChange={e => setFormData({...formData, expenseCategory: e.target.value})}>
|
||||||
|
<option value="FOOD">Ăn uống</option>
|
||||||
|
<option value="TRANSPORT">Di chuyển</option>
|
||||||
|
<option value="ACCOMMODATION">Chỗ ở</option>
|
||||||
|
<option value="TICKET">Vé tham quan</option>
|
||||||
|
<option value="OTHER">Khác</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-gray-600 mb-1">Dịch vụ / Mô tả</label>
|
||||||
|
<input className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||||
|
placeholder="Ví dụ: Ăn trưa, taxi, vé..."
|
||||||
|
value={formData.expenseDescription} onChange={e => setFormData({...formData, expenseDescription: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-gray-600 mb-1">Ghi chú chi phí</label>
|
||||||
|
<textarea className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none resize-none text-sm" rows={2}
|
||||||
|
placeholder="Ghi chú thêm..."
|
||||||
|
value={formData.expenseNote} onChange={e => setFormData({...formData, expenseNote: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-gray-600 mb-1">Thành viên đã thanh toán</label>
|
||||||
|
<select className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||||
|
value={formData.paidById} onChange={e => setFormData({...formData, paidById: e.target.value})}>
|
||||||
|
<option value="">-- Chọn người thanh toán --</option>
|
||||||
|
{currentTour?.participants?.map((p: any) => {
|
||||||
|
const name = p.user?.name;
|
||||||
|
const email = p.user?.email;
|
||||||
|
const label = [name, email && name ? `(${email})` : email].filter(Boolean).join(' ');
|
||||||
|
return (
|
||||||
|
<option key={p.userId} value={p.userId}>{label || p.userId}</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Gán vào chặng</label>
|
||||||
|
<select required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||||
|
value={currentLegId} onChange={e => setFormData({...formData, legId: e.target.value})}>
|
||||||
|
{legs.map(leg => (
|
||||||
|
<option key={leg.id} value={leg.id}>Chặng {leg.sequence}: {leg.note || 'Không có tên'}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Vĩ độ</label>
|
||||||
|
<input type="number" step="any" required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||||
|
value={formData.latitude} onChange={e => setFormData({...formData, latitude: e.target.value as any})} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Kinh độ</label>
|
||||||
|
<input type="number" step="any" required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||||
|
value={formData.longitude} onChange={e => setFormData({...formData, longitude: e.target.value as any})} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Loại</label>
|
||||||
|
<select className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||||
|
value={formData.type} onChange={e => setFormData({...formData, type: e.target.value as any})}>
|
||||||
|
<option value="VISIT">Tham quan</option>
|
||||||
|
<option value="EAT">Ăn uống</option>
|
||||||
|
<option value="REST">Nghỉ ngơi</option>
|
||||||
|
<option value="MOVE">Di chuyển</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Bắt đầu</label>
|
||||||
|
<input type="datetime-local" className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||||
|
value={formData.plannedStart} onChange={e => setFormData({...formData, plannedStart: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button disabled={isLoading} className="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl flex items-center justify-center gap-2 mt-4">
|
||||||
|
{isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : buttonText}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Search, UserPlus, Loader2, Shield, ShieldAlert } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AddMemberModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
tourId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId }) => {
|
||||||
|
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 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(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('');
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!selectedUser) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ userId: selectedUser, role }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || 'Thêm thành viên thất bại');
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message);
|
||||||
|
} 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" /> Thêm thành viên
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-500">Chọn người dùng và phân quyền cho tour này.</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 className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
className="w-full pl-9 pr-4 py-2.5 bg-gray-50 border border-gray-100 rounded-xl outline-none text-sm"
|
||||||
|
placeholder="Tìm theo tên hoặc email..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onBlur={fetchUsers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{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">
|
||||||
|
{users.map((u) => {
|
||||||
|
const isSelected = selectedUser === u.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={u.id}
|
||||||
|
onClick={() => setSelectedUser(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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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 && users.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 thêm...' : 'Thêm vào tour'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import { LandingPage } from './LandingPage.js';
|
|||||||
import { TourDetailPage } from './TourDetailPage.js';
|
import { TourDetailPage } from './TourDetailPage.js';
|
||||||
import { ExploreMap } from './ExploreMap.js';
|
import { ExploreMap } from './ExploreMap.js';
|
||||||
import { SignupPage } from './SignupPage.js';
|
import { SignupPage } from './SignupPage.js';
|
||||||
|
import { useTourStore } from './useTourStore.js';
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
type View = 'landing' | 'explore' | 'detail' | 'signup';
|
type View = 'landing' | 'explore' | 'detail' | 'signup';
|
||||||
@@ -10,6 +11,7 @@ const App = () => {
|
|||||||
const [isInitialSetup, setIsInitialSetup] = useState(false);
|
const [isInitialSetup, setIsInitialSetup] = useState(false);
|
||||||
const [user, setUser] = useState<any>(null);
|
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 [isUserLoaded, setIsUserLoaded] = useState(false); // Trạng thái để biết user đã được load từ localStorage chưa
|
||||||
|
const fetchTour = useTourStore(state => state.fetchTour);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Tự động xác định địa chỉ IP của Backend dựa trên hostname hiện tại
|
// 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')}
|
onBack={() => setView('landing')}
|
||||||
onLogout={user ? handleLogout : undefined}
|
onLogout={user ? handleLogout : undefined}
|
||||||
user={user}
|
user={user}
|
||||||
|
onViewTour={(id) => {
|
||||||
|
fetchTour(id);
|
||||||
|
setView('detail');
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{view === 'detail' && (
|
{view === 'detail' && (
|
||||||
<TourDetailPage />
|
<TourDetailPage onBack={() => setView('explore')} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { X, Map as MapIcon, Loader2 } 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);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const tour = await createTour({ title, startDate, endDate });
|
||||||
|
onSuccess(tour);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
alert('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-8">
|
||||||
|
<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" /> Tạo Tour mới
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<button
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full py-4 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 ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Xác nhận tạo Tour'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+96
-24
@@ -1,10 +1,13 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
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 L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { useTourStore } from './useTourStore.js';
|
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 { UserManagementModal } from './UserManagementModal.js';
|
||||||
|
import { CreateTourModal } from './CreateTourModal.js';
|
||||||
|
|
||||||
// Fix lỗi icon mặc định của Leaflet
|
// Fix lỗi icon mặc định của Leaflet
|
||||||
const DefaultIcon = L.icon({
|
const DefaultIcon = L.icon({
|
||||||
@@ -24,17 +27,59 @@ function RecenterMap({ position }: { position: [number, number] }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ExploreMap = ({ onBack, onLogout, user }: { onBack: () => void, onLogout?: () => void, user?: any }) => {
|
// Component Helper để theo dõi sự di chuyển của người dùng trên bản đồ
|
||||||
const { publicTours, fetchPublicTours } = useTourStore();
|
function MapTracker() {
|
||||||
const [userPos, setUserPos] = useState<[number, number]>([10.7769, 106.7009]); // Mặc định TP.HCM
|
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 [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPublicTours();
|
fetchPublicTours();
|
||||||
|
|
||||||
|
// Nếu không có vị trí lưu từ trước, mới yêu cầu lấy vị trí hiện tại của thiết bị
|
||||||
|
if (!initialViewState) {
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(pos) => setUserPos([pos.coords.latitude, pos.coords.longitude]),
|
(pos) => {
|
||||||
|
const posArray: [number, number] = [pos.coords.latitude, pos.coords.longitude];
|
||||||
|
setUserPos(posArray);
|
||||||
|
setMapCenter(posArray);
|
||||||
|
},
|
||||||
() => console.log("Không thể lấy vị trí người dùng")
|
() => console.log("Không thể lấy vị trí người dùng")
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// Cập nhật store để đồng bộ với vị trí khởi tạo từ cache
|
||||||
|
setMapCenter(initialViewState.center);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -69,6 +114,17 @@ export const ExploreMap = ({ onBack, onLogout, user }: { onBack: () => void, onL
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Nút tạo Tour mới */}
|
||||||
|
{user && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
|
className="absolute top-6 right-80 z-[1000] bg-green-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center gap-2 font-bold"
|
||||||
|
>
|
||||||
|
<Navigation className="w-5 h-5" />
|
||||||
|
<span className="hidden sm:inline">Tạo Tour mới</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header Overlay */}
|
{/* Header Overlay */}
|
||||||
<div className="absolute top-6 left-20 z-[1000] bg-white/90 backdrop-blur-md px-6 py-3 rounded-2xl shadow-xl border border-white/20 hidden sm:block">
|
<div className="absolute top-6 left-20 z-[1000] bg-white/90 backdrop-blur-md px-6 py-3 rounded-2xl shadow-xl border border-white/20 hidden sm:block">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -77,25 +133,38 @@ export const ExploreMap = ({ onBack, onLogout, user }: { onBack: () => void, onL
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MapContainer center={userPos} zoom={13} className="h-full w-full">
|
<MapContainer
|
||||||
|
center={userPos}
|
||||||
|
zoom={mapZoom}
|
||||||
|
className="h-full w-full"
|
||||||
|
preferCanvas={true}
|
||||||
|
>
|
||||||
<TileLayer
|
<TileLayer
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
attribution='© OpenStreetMap contributors'
|
attribution='© OpenStreetMap contributors'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 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 độ */}
|
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
|
||||||
<RecenterMap position={userPos} />
|
<RecenterMap position={userPos} />
|
||||||
|
|
||||||
|
<MarkerClusterGroup chunkedLoading>
|
||||||
{publicTours.map((tour) => {
|
{publicTours.map((tour) => {
|
||||||
const place = tour.legs?.[0]?.places?.[0];
|
const startLoc = tour.legs?.[0]?.locations?.[0];
|
||||||
if (!place) return null;
|
if (!startLoc) return null;
|
||||||
|
|
||||||
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
|
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<React.Fragment key={tour.id}>
|
||||||
|
{/* Tour Marker - Bong bóng chứa thumbnail. Click chuyển vào Dashboard */}
|
||||||
<Marker
|
<Marker
|
||||||
key={tour.id}
|
position={[startLoc.latitude, startLoc.longitude]}
|
||||||
position={[place.latitude, place.longitude]}
|
eventHandlers={{
|
||||||
|
click: () => onViewTour(tour.id)
|
||||||
|
}}
|
||||||
icon={L.divIcon({
|
icon={L.divIcon({
|
||||||
className: 'custom-bubble',
|
className: 'custom-bubble',
|
||||||
html: `
|
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">
|
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
|
||||||
<img src="${tourImage}" class="w-full h-full object-cover" />
|
<img src="${tourImage}" class="w-full h-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full p-1 border-2 border-white">
|
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
|
S
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
iconSize: [48, 48],
|
iconSize: [48, 48],
|
||||||
|
iconAnchor: [24, 24]
|
||||||
})}
|
})}
|
||||||
>
|
/>
|
||||||
<Popup className="custom-popup">
|
</React.Fragment>
|
||||||
<div className="p-1 max-w-[200px]">
|
|
||||||
<img src={tourImage} className="w-full h-32 object-cover rounded-lg mb-2" />
|
|
||||||
<h4 className="font-bold text-gray-900 leading-tight mb-1">{tour.title}</h4>
|
|
||||||
<button className="text-xs font-bold text-blue-600 flex items-center gap-1">
|
|
||||||
Xem chi tiết hình ảnh <ImageIcon className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Popup>
|
|
||||||
</Marker>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</MarkerClusterGroup>
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
|
|
||||||
{/* Admin Modal */}
|
{/* Admin Modal */}
|
||||||
<UserManagementModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} />
|
<UserManagementModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} />
|
||||||
|
|
||||||
|
{/* Create Tour Modal */}
|
||||||
|
<CreateTourModal
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
|
onSuccess={(tour) => {
|
||||||
|
fetchTour(tour.id);
|
||||||
|
onViewTour(tour.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+272
-39
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
||||||
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle } from 'lucide-react';
|
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List } from 'lucide-react';
|
||||||
import { useTourStore } from './useTourStore.js';
|
import { useTourStore } from './useTourStore.js';
|
||||||
|
|
||||||
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
|
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
|
||||||
@@ -19,42 +19,197 @@ const TimeVariance = ({ planned, actual }: { planned: string, actual: string | n
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ItineraryTimeline = () => {
|
const calculateDistance = (lat1: number, lon1: number, lat2: number, lon2: number) => {
|
||||||
const { legs } = useTourStore();
|
const p = 0.017453292519943295; // Math.PI / 180
|
||||||
|
const c = Math.cos;
|
||||||
|
const a = 0.5 - c((lat2 - lat1) * p) / 2 +
|
||||||
|
c(lat1 * p) * c(lat2 * p) *
|
||||||
|
(1 - c((lon2 - lon1) * p)) / 2;
|
||||||
|
return 12742 * Math.asin(Math.sqrt(a)); // 2 * R; R = 6371 km
|
||||||
|
};
|
||||||
|
|
||||||
const toggleComplete = async (placeId: number) => {
|
const formatTravelTime = (minutes: number) => {
|
||||||
// Gọi API PATCH /api/v1/places/:id để cập nhật trạng thái
|
if (minutes < 60) return `${minutes} phút`;
|
||||||
console.log("Toggle status for place:", placeId);
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
return mins > 0 ? `${hours} giờ ${mins} phút` : `${hours} giờ`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ItineraryTimeline = ({
|
||||||
|
onAddLocation,
|
||||||
|
onEditLocation
|
||||||
|
}: { onAddLocation?: (legId: string) => void, onEditLocation?: (location: any) => void }) => {
|
||||||
|
const { currentTour, legs, optimizeRouting, userRole, addLeg, updateLeg, deleteLeg, initializeLegs, deleteLocation } = useTourStore();
|
||||||
|
|
||||||
|
const 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) => {
|
||||||
|
if (window.confirm("Bạn có chắc chắn muốn xóa chặng này?")) {
|
||||||
|
try {
|
||||||
|
await deleteLeg(legId);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteLocation = async (id: string) => {
|
||||||
|
if (window.confirm("Bạn có chắc chắn muốn xóa địa điểm này?")) {
|
||||||
|
try {
|
||||||
|
await deleteLocation(id);
|
||||||
|
} catch (err: any) { alert(err.message); }
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto p-4 sm:p-6 bg-gray-50 min-h-screen">
|
<div className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen">
|
||||||
<h2 className="text-2xl font-bold text-gray-800 mb-8 px-2">Lộ trình chuyến đi</h2>
|
<div className="px-2 pt-4">
|
||||||
|
{legs.length === 0 ? (
|
||||||
<div className="space-y-8">
|
<div className="text-center py-20 bg-white rounded-3xl border-2 border-dashed border-gray-200">
|
||||||
{legs.map((leg, legIdx) => (
|
<List className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||||
<div key={leg.id} className="relative">
|
<p className="text-gray-500 font-medium">Chưa có chặng nào trong lộ trình.</p>
|
||||||
{/* Leg Header */}
|
|
||||||
<div className="flex items-center mb-4 px-2">
|
|
||||||
<div className="bg-blue-600 text-white text-sm font-bold px-3 py-1 rounded-full shadow-sm">
|
|
||||||
Chặng {leg.sequenceNumber}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4 h-[1px] flex-1 bg-gray-200" />
|
) : (
|
||||||
|
legs.map((leg, legIdx) => {
|
||||||
|
const totalDwellMinutes = leg.locations.reduce((acc: number, loc: any) => {
|
||||||
|
if (loc.plannedStart && loc.plannedEnd) {
|
||||||
|
return acc + differenceInMinutes(parseISO(loc.plannedEnd), parseISO(loc.plannedStart));
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Xác định địa điểm cuối cùng của chặng trước đó để hiển thị tính liên tục
|
||||||
|
const prevLegLastLoc = legIdx > 0 ? legs[legIdx - 1]?.locations.slice(-1)[0] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={leg.id} className="relative mb-12 animate-in fade-in slide-in-from-bottom-4 duration-300">
|
||||||
|
{/* Leg Header */}
|
||||||
|
<div className="sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2">
|
||||||
|
<div className="font-black text-blue-600 text-xl truncate flex-1 flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm">
|
||||||
|
{leg.sequence}
|
||||||
|
</span>
|
||||||
|
{leg.note || `Chi tiết Chặng ${leg.sequence}`}
|
||||||
|
</div>
|
||||||
|
{prevLegLastLoc && (
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-gray-400 uppercase tracking-widest ml-10">
|
||||||
|
<Navigation className="w-2.5 h-2.5 rotate-90" /> Tiếp nối từ {prevLegLastLoc.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
{['OWNER', 'MANAGER'].includes(userRole || '') && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => onAddLocation?.(leg.id)}
|
||||||
|
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
|
||||||
|
title="Thêm địa điểm vào chặng này"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditLeg(leg)}
|
||||||
|
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteLeg(leg.id)}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{leg.totalDistance !== undefined && (
|
||||||
|
<div className="hidden sm:flex items-center gap-2">
|
||||||
|
<div className="text-xs font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded-lg border border-blue-100">
|
||||||
|
{leg.totalDistance} km
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-bold text-gray-500 bg-gray-50 px-2 py-1 rounded-lg border border-gray-100 flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
~ {formatTravelTime(Math.round((leg.totalDistance / 35) * 60))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{totalDwellMinutes > 0 && (
|
||||||
|
<div className="hidden md:flex text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
Dừng: {formatTravelTime(totalDwellMinutes)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{['OWNER', 'MANAGER'].includes(userRole || '') && legs.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => optimizeRouting(leg.id)}
|
||||||
|
className="ml-auto flex items-center gap-1.5 text-xs font-bold text-indigo-600 hover:text-indigo-700 bg-indigo-50 hover:bg-indigo-100 px-3 py-1.5 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Zap className="w-3 h-3" />
|
||||||
|
Tối ưu
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vertical Line for the whole leg */}
|
{/* Vertical Line for the whole leg */}
|
||||||
<div className="absolute left-6 top-10 bottom-0 w-0.5 bg-gray-200 -z-0" />
|
{/* Mở rộng đường kẻ xuống dưới (bottom-[-3rem]) để nối liền với chặng tiếp theo */}
|
||||||
|
<div className={`absolute left-6 top-16 ${legIdx === legs.length - 1 ? 'bottom-0' : 'bottom-[-3.5rem]'} w-0.5 bg-blue-100 -z-0`} />
|
||||||
|
|
||||||
<div className="space-y-6 ml-2">
|
<div className="ml-2">
|
||||||
{leg.places.map((place) => (
|
{leg.locations.map((location, idx) => {
|
||||||
<div key={place.id} className="relative flex group">
|
// Logic quan trọng: Gán điểm cuối chặng này nối với điểm đầu chặng sau
|
||||||
|
const nextLocation = leg.locations[idx + 1] || legs[legIdx + 1]?.locations[0];
|
||||||
|
const distanceToNext = nextLocation
|
||||||
|
? calculateDistance(location.latitude, location.longitude, nextLocation.latitude, nextLocation.longitude)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const averageSpeed = 35; // km/h
|
||||||
|
const travelTimeMinutes = distanceToNext ? Math.round((distanceToNext / averageSpeed) * 60) : null;
|
||||||
|
|
||||||
|
const dwellMinutes = (location.plannedStart && location.plannedEnd)
|
||||||
|
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const locationExpense = leg.expenses?.find((e: any) => e.locationId === location.id);
|
||||||
|
|
||||||
|
const isStartPoint = legs[0]?.id === leg.id && idx === 0;
|
||||||
|
const isEndPoint = legs[legs.length - 1]?.id === leg.id && idx === leg.locations.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={location.id}>
|
||||||
|
<div className="relative flex group mb-6">
|
||||||
{/* Timeline Node */}
|
{/* Timeline Node */}
|
||||||
<div className="z-10 mt-1.5 mr-4">
|
<div className="z-10 mt-1.5 mr-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleComplete(place.id)}
|
onClick={() => toggleComplete(location.id)}
|
||||||
className={`transition-colors duration-200 ${place.isCompleted ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`}
|
className={`transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`}
|
||||||
>
|
>
|
||||||
{place.isCompleted ? (
|
{location.status === 'COMPLETED' ? (
|
||||||
<CheckCircle2 className="w-8 h-8 bg-white rounded-full" />
|
<CheckCircle2 className="w-8 h-8 bg-white rounded-full" />
|
||||||
) : (
|
) : (
|
||||||
<Circle className="w-8 h-8 bg-white rounded-full fill-white" />
|
<Circle className="w-8 h-8 bg-white rounded-full fill-white" />
|
||||||
@@ -64,46 +219,124 @@ export const ItineraryTimeline = () => {
|
|||||||
|
|
||||||
{/* Card Content */}
|
{/* Card Content */}
|
||||||
<div className={`flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${
|
<div className={`flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${
|
||||||
place.isCompleted ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'
|
location.status === 'COMPLETED' ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'
|
||||||
}`}>
|
}`}>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h3 className={`font-semibold text-lg ${place.isCompleted ? 'text-gray-500 line-through' : 'text-gray-800'}`}>
|
{isStartPoint && (
|
||||||
{place.name}
|
<span className="inline-block px-2 py-0.5 bg-green-100 text-green-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm bắt đầu</span>
|
||||||
|
)}
|
||||||
|
{isEndPoint && (
|
||||||
|
<span className="inline-block px-2 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span>
|
||||||
|
)}
|
||||||
|
<h3 className={`font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`}>
|
||||||
|
{location.name}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center text-sm text-gray-500 mt-1">
|
<div className="flex items-center text-sm text-gray-500 mt-1">
|
||||||
<MapPin className="w-3 h-3 mr-1" />
|
<MapPin className="w-3 h-3 mr-1" />
|
||||||
<span className="truncate max-w-[200px] sm:max-w-md">{place.address}</span>
|
<span className="truncate max-w-[200px] sm:max-w-md">{location.address}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{location.note && (
|
||||||
|
<div className="mt-2 text-xs text-gray-600 bg-gray-50 p-2 rounded-lg border border-gray-100 italic">
|
||||||
|
{location.note}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dwellMinutes !== null && (
|
||||||
|
<div className="flex items-center text-xs text-amber-600 font-medium mt-1">
|
||||||
|
<Clock className="w-3 h-3 mr-1" />
|
||||||
|
<span>Thời gian dừng: {formatTravelTime(dwellMinutes)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{locationExpense && (
|
||||||
|
<div className="mt-2 text-xs text-indigo-600 bg-indigo-50/80 p-2 rounded-lg border border-indigo-100 space-y-1">
|
||||||
|
<div className="flex items-center gap-1 font-bold">
|
||||||
|
<Zap className="w-3 h-3" />
|
||||||
|
<span>Chi phí: {Number(locationExpense.amount).toLocaleString()}đ ({locationExpense.category})</span>
|
||||||
|
</div>
|
||||||
|
{locationExpense.description && (
|
||||||
|
<div className="text-[10px] text-gray-600">Dịch vụ: {locationExpense.description}</div>
|
||||||
|
)}
|
||||||
|
{locationExpense.note && (
|
||||||
|
<div className="text-[10px] text-gray-500 italic">{locationExpense.note}</div>
|
||||||
|
)}
|
||||||
|
{locationExpense.paidBy && (
|
||||||
|
<div className="text-[10px] font-semibold text-indigo-700">Đã thanh toán: {locationExpense.paidBy.name}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-right flex flex-col items-end">
|
<div className="text-right flex flex-col items-end">
|
||||||
<div className="flex items-center text-sm font-medium text-blue-600">
|
<div className="flex items-center text-sm font-medium text-blue-600">
|
||||||
<Clock className="w-3 h-3 mr-1" />
|
<Clock className="w-3 h-3 mr-1" />
|
||||||
{format(parseISO(place.arrivalTime), 'HH:mm')}
|
{location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'}
|
||||||
</div>
|
</div>
|
||||||
{place.isCompleted && place.completedAt && (
|
{location.status === 'COMPLETED' && location.actualStart && (
|
||||||
<div className="text-[10px] text-gray-400 mt-1 italic">
|
<div className="text-[10px] text-gray-400 mt-1 italic">
|
||||||
Thực tế: {format(parseISO(place.completedAt), 'HH:mm')}
|
Thực tế: {format(parseISO(location.actualStart), 'HH:mm')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{['OWNER', 'MANAGER'].includes(userRole || '') && !isStartPoint && !isEndPoint && (
|
||||||
|
<div className="flex gap-1 mt-2">
|
||||||
|
<button onClick={() => onEditLocation?.(location)} className="p-1 text-gray-400 hover:text-blue-600 transition-colors">
|
||||||
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDeleteLocation(location.id)} className="p-1 text-gray-400 hover:text-red-600 transition-colors">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logic tính toán độ lệch thời gian */}
|
{/* Logic tính toán độ lệch thời gian */}
|
||||||
<TimeVariance planned={place.arrivalTime} actual={place.completedAt} />
|
<TimeVariance planned={location.plannedStart || ''} actual={location.actualStart || null} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{leg.notes && (
|
{distanceToNext !== null && travelTimeMinutes !== null && (
|
||||||
<p className="ml-14 mt-4 text-sm text-gray-400 italic">
|
<div className="ml-4 -mt-4 mb-2 flex items-center gap-3">
|
||||||
* {leg.notes}
|
<div className="w-8 flex justify-center">
|
||||||
</p>
|
<Navigation className="w-3 h-3 text-blue-400 rotate-180" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[10px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100">
|
||||||
|
{distanceToNext.toFixed(2)} km
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-bold text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full border border-gray-100 flex items-center gap-1">
|
||||||
|
<Clock className="w-2.5 h-2.5" />
|
||||||
|
~ {formatTravelTime(travelTimeMinutes)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+36
-3
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { User, Mail, Lock, ArrowRight, ChevronLeft, Compass } from 'lucide-react';
|
import { User, Mail, Lock, ArrowRight, ChevronLeft, Compass, Phone, MapPin } from 'lucide-react';
|
||||||
|
|
||||||
interface SignupPageProps {
|
interface SignupPageProps {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
@@ -11,7 +11,9 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
|
|||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
confirmPassword: ''
|
confirmPassword: '',
|
||||||
|
phone: '',
|
||||||
|
address: ''
|
||||||
});
|
});
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -35,7 +37,9 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
name: formData.name
|
name: formData.name,
|
||||||
|
phone: formData.phone || undefined,
|
||||||
|
address: formData.address || undefined
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,6 +126,35 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-bold text-gray-700 ml-1">Số điện thoại</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
placeholder="0912 345 678"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||||
|
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-bold text-gray-700 ml-1">Địa chỉ</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Quận 1, TP.HCM"
|
||||||
|
value={formData.address}
|
||||||
|
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
||||||
|
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-bold text-gray-700 ml-1">Mật khẩu</label>
|
<label className="text-sm font-bold text-gray-700 ml-1">Mật khẩu</label>
|
||||||
|
|||||||
+546
-42
@@ -1,52 +1,351 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { ItineraryTimeline } from './ItineraryTimeline.js';
|
import { ItineraryTimeline } from './ItineraryTimeline.js';
|
||||||
import { ExpenseManager } from './ExpenseManager.js';
|
import { ExpenseManager } from './ExpenseManager.js';
|
||||||
import { useTourStore } from './useTourStore.js';
|
import { useTourStore } from './useTourStore.js';
|
||||||
|
import { AddLocationModal } from './AddLocationModal.js';
|
||||||
|
import { AddMemberModal } from './AddMemberModal.js';
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline } from 'react-leaflet';
|
||||||
|
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||||
|
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
||||||
|
import { useMap } from 'react-leaflet';
|
||||||
import {
|
import {
|
||||||
Map as MapIcon,
|
Map as MapIcon,
|
||||||
Wallet,
|
Wallet,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Calendar,
|
Calendar,
|
||||||
Users,
|
Users,
|
||||||
ChevronLeft
|
ChevronLeft,
|
||||||
|
Settings,
|
||||||
|
Quote,
|
||||||
|
Plus,
|
||||||
|
List,
|
||||||
|
Map as MapIconLucide,
|
||||||
|
MapPin,
|
||||||
|
Flag
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
|
||||||
export const TourDetailPage = () => {
|
// Định nghĩa kiểu dữ liệu cho Địa điểm để khớp với Schema Prisma
|
||||||
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo'>('plan');
|
type LocationType = 'MOVE' | 'VISIT' | 'REST' | 'EAT';
|
||||||
const { currentTour, fetchTour, userRole } = useTourStore();
|
|
||||||
|
// Fix lỗi icon mặc định của Leaflet cho môi trường Vite
|
||||||
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||||
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||||
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Định nghĩa các static icons để ngăn chặn việc khởi tạo lại liên tục gây crash khi unmount
|
||||||
|
const START_ICON = L.divIcon({
|
||||||
|
className: 'custom-marker-s',
|
||||||
|
html: `<div class="w-6 h-6 bg-blue-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-[10px] font-black text-white">S</div>`,
|
||||||
|
iconSize: [24, 24],
|
||||||
|
iconAnchor: [12, 12]
|
||||||
|
});
|
||||||
|
|
||||||
|
const END_ICON = L.divIcon({
|
||||||
|
className: 'custom-marker-e',
|
||||||
|
html: `<div class="w-6 h-6 bg-green-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-[10px] font-black text-white">E</div>`,
|
||||||
|
iconSize: [24, 24],
|
||||||
|
iconAnchor: [12, 12]
|
||||||
|
});
|
||||||
|
|
||||||
|
const VISIT_ICON = L.divIcon({
|
||||||
|
className: 'custom-marker-v',
|
||||||
|
html: `<div class="w-4 h-4 bg-indigo-500 rounded-full border-2 border-white shadow-md"></div>`,
|
||||||
|
iconSize: [16, 16],
|
||||||
|
iconAnchor: [8, 8]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Component Helper để tự động điều chỉnh khung nhìn bản đồ bao phủ toàn bộ lộ trình
|
||||||
|
const MapTourBounds = ({ locations }: { locations: any[] }) => {
|
||||||
|
const map = useMap();
|
||||||
|
// Tạo một key dựa trên giá trị tọa độ để tránh chạy lại khi chỉ thay đổi tham chiếu mảng
|
||||||
|
const locKey = useMemo(() => JSON.stringify(locations.map(l => [l.latitude, l.longitude])), [locations]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Gọi ID 1 (Dữ liệu từ file seed) để kiểm tra
|
if (locations.length > 0) {
|
||||||
fetchTour(1);
|
const bounds = L.latLngBounds(locations.map(l => [l.latitude, l.longitude]));
|
||||||
}, [fetchTour]);
|
if (locations.length === 1) {
|
||||||
|
// Chỉ thực hiện nếu bản đồ chưa ở đúng vị trí (tránh trigger moveend liên tục)
|
||||||
|
map.setView([locations[0].latitude, locations[0].longitude], 15, { animate: true });
|
||||||
|
} else {
|
||||||
|
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [locKey, map]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
// Menu ngữ cảnh cho bản đồ
|
||||||
|
const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.LatLng) => void }) => {
|
||||||
|
const [menuPos, setMenuPos] = useState<{ x: number, y: number, latlng: L.LatLng } | null>(null);
|
||||||
|
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Sử dụng selector để tránh re-render khi mapCenter thay đổi
|
||||||
|
const legs = useTourStore(state => state.legs);
|
||||||
|
const setMapCenter = useTourStore(state => state.setMapCenter);
|
||||||
|
|
||||||
|
useMapEvents({
|
||||||
|
contextmenu: (e) => {
|
||||||
|
// Ngăn menu mặc định của trình duyệt hiện lên.
|
||||||
|
if (e.originalEvent) {
|
||||||
|
L.DomEvent.preventDefault(e.originalEvent);
|
||||||
|
L.DomEvent.stopPropagation(e.originalEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[MAP] Context menu triggered at: ${e.latlng.lat}, ${e.latlng.lng}`);
|
||||||
|
setMenuPos({ x: e.containerPoint.x, y: e.containerPoint.y, latlng: e.latlng });
|
||||||
|
},
|
||||||
|
|
||||||
|
moveend: (e) => {
|
||||||
|
const map = e.target;
|
||||||
|
const center = map.getCenter();
|
||||||
|
const zoom = map.getZoom();
|
||||||
|
const coords: [number, number] = [center.lat, center.lng];
|
||||||
|
|
||||||
|
// Chỉ cập nhật store nếu tọa độ thay đổi đáng kể (> 0.0001) để tránh loop
|
||||||
|
const currentStored = useTourStore.getState().mapCenter;
|
||||||
|
const diff = Math.abs(currentStored[0] - coords[0]) + Math.abs(currentStored[1] - coords[1]);
|
||||||
|
|
||||||
|
if (diff > 0.0001) {
|
||||||
|
setMapCenter(coords);
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('map_view_state', JSON.stringify({ center: coords, zoom }));
|
||||||
|
},
|
||||||
|
click: () => setMenuPos(null),
|
||||||
|
dragstart: () => setMenuPos(null),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ngăn chặn các sự kiện của bản đồ khi tương tác với menu
|
||||||
|
useEffect(() => {
|
||||||
|
if (menuPos && menuRef.current) {
|
||||||
|
L.DomEvent.disableClickPropagation(menuRef.current);
|
||||||
|
L.DomEvent.disableScrollPropagation(menuRef.current);
|
||||||
|
}
|
||||||
|
}, [menuPos]);
|
||||||
|
|
||||||
|
if (!menuPos) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="absolute z-[2000] bg-white rounded-2xl shadow-2xl border border-gray-100 py-2 w-48 animate-in zoom-in-95 duration-200"
|
||||||
|
style={{ top: menuPos.y, left: menuPos.x }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<button onClick={() => { onAction('START', menuPos.latlng); setMenuPos(null); }} className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-gray-700 flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-blue-600" /> Bắt đầu từ đây
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { onAction('END', menuPos.latlng); setMenuPos(null); }} className="w-full text-left px-4 py-2 hover:bg-green-50 text-sm font-bold text-gray-700 flex items-center gap-2 border-b border-gray-50">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-green-600" /> Kết thúc ở đây
|
||||||
|
</button>
|
||||||
|
<div className="px-4 py-2 text-[10px] font-black text-gray-400 uppercase tracking-widest">Thêm vào chặng</div>
|
||||||
|
{legs.map(leg => (
|
||||||
|
<button
|
||||||
|
key={leg.id}
|
||||||
|
onClick={() => { onAction(`ADD_TO_LEG_${leg.id}`, menuPos.latlng); setMenuPos(null); }}
|
||||||
|
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-medium text-gray-600 truncate"
|
||||||
|
>
|
||||||
|
Chặng {leg.sequence}: {leg.note || 'Không có ghi chú'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings'>('plan');
|
||||||
|
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
|
||||||
|
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
|
||||||
|
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
|
||||||
|
const [targetLegId, setTargetLegId] = useState<string | null>(null);
|
||||||
|
const [editingLocation, setEditingLocation] = useState<any>(null);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// Gom các store actions/state lại để tối ưu hóa re-render
|
||||||
|
const {
|
||||||
|
currentTour, legs, fetchTour, fetchPublicTours, publicTours,
|
||||||
|
userRole, mapCenter, setMapCenter, updateTourStartPoint,
|
||||||
|
updateTourEndPoint, initializeLegs, addLocation, addMember
|
||||||
|
} = useTourStore();
|
||||||
|
|
||||||
|
const canEdit = ['OWNER', 'MANAGER'].includes(userRole || '');
|
||||||
|
|
||||||
|
const [mapZoom] = useState(initialViewState?.zoom || 13);
|
||||||
|
|
||||||
|
// Memoize danh sách tọa độ để MapTourBounds không bị trigger thừa
|
||||||
|
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialViewState) {
|
||||||
|
setMapCenter(initialViewState.center);
|
||||||
|
}
|
||||||
|
const loadData = async () => {
|
||||||
|
// Nếu chưa có tour nào trong store, thử tải danh sách public trước
|
||||||
|
if (publicTours.length === 0) {
|
||||||
|
await fetchPublicTours();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Lấy tour đầu tiên từ danh sách khám phá để hiển thị demo
|
||||||
|
if (publicTours.length > 0 && !currentTour) {
|
||||||
|
fetchTour(publicTours[0].id);
|
||||||
|
}
|
||||||
|
}, [publicTours, currentTour, fetchTour]);
|
||||||
|
|
||||||
|
// Hàm xử lý khai báo số chặng
|
||||||
|
const handleDeclareLegs = async () => {
|
||||||
|
const countStr = window.prompt("Chuyến đi này bạn muốn chia làm bao nhiêu chặng?", "3");
|
||||||
|
const count = parseInt(countStr || "0");
|
||||||
|
if (count > 0 && currentTour) {
|
||||||
|
await initializeLegs(currentTour.id, count);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hàm xử lý các hành động từ Context Menu của bản đồ
|
||||||
|
const handleMapAction = async (action: string, latlng: L.LatLng) => {
|
||||||
|
// Bước 1: Lấy tọa độ (lat, lng) tại vị trí click (đã nhận qua tham số latlng)
|
||||||
|
console.log(`[FRONTEND] Triggered ${action} at:`, { lat: latlng.lat, lng: latlng.lng });
|
||||||
|
|
||||||
|
// Đảm bảo có tour và ít nhất một chặng để ghim
|
||||||
|
const currentLegs = useTourStore.getState().legs;
|
||||||
|
if (!currentTour || currentLegs.length === 0) {
|
||||||
|
alert("Tour chưa có chặng nào. Vui lòng tạo chặng (Leg) trước khi thực hiện.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetLegId = currentLegs[0].id; // Mặc định là chặng đầu
|
||||||
|
let defaultName = "Địa điểm mới";
|
||||||
|
let locationType: LocationType = 'VISIT'; // Mặc định là tham quan
|
||||||
|
|
||||||
|
if (action === 'START') {
|
||||||
|
defaultName = "Điểm bắt đầu";
|
||||||
|
locationType = 'MOVE'; // Điểm bắt đầu thường liên quan đến di chuyển
|
||||||
|
}
|
||||||
|
if (action === 'END') {
|
||||||
|
targetLegId = currentLegs[currentLegs.length - 1].id;
|
||||||
|
defaultName = "Điểm kết thúc";
|
||||||
|
locationType = 'MOVE'; // Điểm kết thúc cũng liên quan đến di chuyển
|
||||||
|
}
|
||||||
|
if (action.startsWith('ADD_TO_LEG_')) {
|
||||||
|
targetLegId = action.replace('ADD_TO_LEG_', '');
|
||||||
|
// Loại địa điểm mặc định vẫn là VISIT nếu thêm vào chặng cụ thể
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bước 2: Gửi request đến dịch vụ bản đồ để phân tích tọa độ thành tên địa điểm cụ thể
|
||||||
|
let detectedName = "";
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latlng.lat}&lon=${latlng.lng}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const addr = data.address;
|
||||||
|
// Ưu tiên lấy tên Location/Tòa nhà/Tên đường, bỏ qua Tỉnh/Thành phố nếu có thông tin chi tiết hơn
|
||||||
|
detectedName = addr.amenity || addr.building || addr.historic || addr.tourist ||
|
||||||
|
addr.shop || addr.office || addr.leisure || addr.attraction ||
|
||||||
|
addr.road || addr.neighbourhood || addr.suburb ||
|
||||||
|
data.display_name?.split(',')[0] || "";
|
||||||
|
console.log(`[FRONTEND] Geocoding Result: "${detectedName}"`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[FRONTEND] Reverse Geocoding failed:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === 'START') {
|
||||||
|
// Bước 3: Mutate State & UI - Đặt startLocationName = resolvedPlaceName
|
||||||
|
const resolvedPlaceName = detectedName || "Điểm xuất phát";
|
||||||
|
console.log(`[FRONTEND] Updating START point to: ${resolvedPlaceName} at`, latlng);
|
||||||
|
await updateTourStartPoint(currentTour.id, {
|
||||||
|
name: resolvedPlaceName,
|
||||||
|
latitude: latlng.lat,
|
||||||
|
longitude: latlng.lng,
|
||||||
|
});
|
||||||
|
console.log("[FRONTEND] START point updated successfully.");
|
||||||
|
// Giao diện Top Banner và Ghim màu xanh sẽ tự động cập nhật
|
||||||
|
// khi store fetch lại dữ liệu tour và re-render.
|
||||||
|
} else if (action === 'END') {
|
||||||
|
// Bước 2: Thiết lập Điểm kết thúc
|
||||||
|
const finalName = detectedName || "Điểm kết thúc";
|
||||||
|
await updateTourEndPoint(currentTour.id, {
|
||||||
|
name: finalName,
|
||||||
|
latitude: latlng.lat,
|
||||||
|
longitude: latlng.lng,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Đối với việc thêm địa điểm vào chặng, vẫn sử dụng Prompt để người dùng đặt tên theo ý muốn
|
||||||
|
const name = window.prompt("Xác nhận tên địa điểm tham quan:", detectedName || defaultName);
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
await addLocation(currentTour.id, {
|
||||||
|
name,
|
||||||
|
address: '',
|
||||||
|
latitude: latlng.lat,
|
||||||
|
longitude: latlng.lng,
|
||||||
|
legId: targetLegId,
|
||||||
|
type: locationType as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// Xử lý lỗi từ API (Ví dụ: Tour chưa có chặng nào)
|
||||||
|
if (error.message.includes('Không tìm thấy chặng')) {
|
||||||
|
alert("Lỗi: Bạn cần tạo ít nhất một Chặng (Leg) trước khi xác định điểm Bắt đầu/Kết thúc.");
|
||||||
|
} else {
|
||||||
|
alert("Đã xảy ra lỗi: " + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Xác định Điểm xuất phát và Điểm kết thúc hiển thị dưới widget tài chính
|
||||||
|
const startPoint = legs[0]?.locations[0];
|
||||||
|
const lastLeg = legs[legs.length - 1];
|
||||||
|
const endPoint = lastLeg?.locations[lastLeg.locations.length - 1];
|
||||||
|
|
||||||
// Định nghĩa các tab dựa trên quyền hạn (RBAC) từ store
|
// Định nghĩa các tab dựa trên quyền hạn (RBAC) từ store
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: ['OWNER', 'EDITOR', 'MEMBER_PLAN_ONLY'].includes(userRole || '') },
|
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: true },
|
||||||
{ id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'EDITOR'].includes(userRole || '') },
|
{ id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '') },
|
||||||
{ id: 'photo', label: 'Ảnh', icon: ImageIcon, visible: true },
|
{ id: 'photo', label: 'Ảnh', icon: ImageIcon, visible: true },
|
||||||
|
{ id: 'settings', label: 'Cài đặt', icon: Settings, visible: userRole === 'OWNER' },
|
||||||
].filter(t => t.visible);
|
].filter(t => t.visible);
|
||||||
|
|
||||||
// Tự động chuyển tab nếu người dùng không có quyền xem 'plan'
|
const hasFinanceAccess = ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
||||||
useEffect(() => {
|
|
||||||
if (userRole === 'MEMBER_PHOTO_ONLY') {
|
const travelQuotes = [
|
||||||
setActiveTab('photo');
|
"Đừng nghe họ nói, hãy tự mình đi xem.",
|
||||||
}
|
"Thế giới là một cuốn sách, và ai không đi du lịch thì chỉ mới đọc được một trang.",
|
||||||
}, [userRole]);
|
"Hành trình ngàn dặm bắt đầu từ một bước chân.",
|
||||||
|
"Đi là để trở về, nhưng với một tâm hồn mới."
|
||||||
|
];
|
||||||
|
|
||||||
|
const randomQuote = useMemo(() => travelQuotes[Math.floor(Math.random() * travelQuotes.length)], []);
|
||||||
|
|
||||||
// Mock dữ liệu header (Trong thực tế sẽ lấy từ currentTour)
|
|
||||||
const tourInfo = {
|
const tourInfo = {
|
||||||
title: currentTour?.title || "Hành trình khám phá TP.HCM",
|
title: currentTour?.title || "Hành trình khám phá TP.HCM",
|
||||||
date: "20 - 21 Tháng 11, 2023",
|
date: currentTour?.startDate ? `${new Date(currentTour.startDate).toLocaleDateString('vi-VN')} - ${new Date(currentTour.endDate).toLocaleDateString('vi-VN')}` : "Chưa xác định ngày",
|
||||||
members: 5,
|
membersCount: currentTour?.participants?.length || 0,
|
||||||
budget: "2.500.000 VND"
|
budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND",
|
||||||
|
coverImage: currentTour?.photos?.[0]?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-gray-50 pb-20">
|
||||||
{/* Top Navigation Bar */}
|
{/* Top Navigation Bar */}
|
||||||
<div className="sticky top-0 z-30 bg-white border-b border-gray-100 px-4 py-3 flex items-center justify-between">
|
<div className="sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-gray-100 px-4 py-3 flex items-center justify-between">
|
||||||
<button className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||||
<ChevronLeft className="w-6 h-6 text-gray-600" />
|
<ChevronLeft className="w-6 h-6 text-gray-600" />
|
||||||
</button>
|
</button>
|
||||||
<h1 className="text-lg font-bold text-gray-800 truncate px-4">
|
<h1 className="text-lg font-bold text-gray-800 truncate px-4">
|
||||||
@@ -56,29 +355,123 @@ export const TourDetailPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tour Header Info */}
|
{/* Tour Header Info */}
|
||||||
<div className="bg-blue-600 text-white p-6 pb-20">
|
<div className="relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end">
|
||||||
|
<img
|
||||||
|
src={tourInfo.coverImage}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover opacity-60"
|
||||||
|
alt="Tour Cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
|
||||||
|
|
||||||
|
<div className="relative z-10 p-6 text-white pt-28 pb-20">
|
||||||
<div className="max-w-2xl mx-auto space-y-4">
|
<div className="max-w-2xl mx-auto space-y-4">
|
||||||
<div className="flex flex-wrap gap-4 text-sm opacity-90">
|
<h2 className="text-3xl font-black tracking-tight drop-shadow-md">{tourInfo.title}</h2>
|
||||||
<div className="flex items-center">
|
{/* Dòng tóm tắt Lộ trình */}
|
||||||
|
<div className="mt-3 text-[11px] sm:text-sm font-bold text-white bg-black/40 backdrop-blur-md px-4 py-2 rounded-xl border border-white/10 inline-flex items-center max-w-full">
|
||||||
|
<span className="text-white/60 mr-1">Lộ trình:</span>
|
||||||
|
<span className="text-blue-300">Điểm xuất phát:</span>
|
||||||
|
<span className="ml-1 text-white banner-location-text" title={startPoint?.name}>{startPoint?.name || '...'}</span>
|
||||||
|
<span className="mx-2 text-white/30">-</span>
|
||||||
|
<span className="text-green-300">Điểm kết thúc:</span>
|
||||||
|
<span className="ml-1 text-white banner-location-text" title={endPoint?.name}>{endPoint?.name || '...'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4 text-sm font-medium opacity-90">
|
||||||
|
<div className="flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10">
|
||||||
<Calendar className="w-4 h-4 mr-1.5" />
|
<Calendar className="w-4 h-4 mr-1.5" />
|
||||||
{tourInfo.date}
|
{tourInfo.date}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10">
|
||||||
<Users className="w-4 h-4 mr-1.5" />
|
<Users className="w-4 h-4 mr-1.5" />
|
||||||
{tourInfo.members} thành viên
|
{tourInfo.membersCount} thành viên
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<span className="text-3xl font-bold">{tourInfo.budget}</span>
|
|
||||||
<span className="text-blue-100 text-sm">dự kiến</span>
|
|
||||||
|
{/* Member Avatars Stack */}
|
||||||
|
<div className="flex items-center gap-2 mt-4">
|
||||||
|
<div className="flex -space-x-3">
|
||||||
|
{currentTour?.participants?.slice(0, 5).map((p: any, i: number) => (
|
||||||
|
<div key={i} 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">
|
||||||
|
<img src={`https://i.pravatar.cc/100?u=${p.userId}`} alt="Avatar" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{tourInfo.membersCount > 5 && (
|
||||||
|
<div className="w-10 h-10 rounded-full border-2 border-white bg-gray-800 flex items-center justify-center text-[10px] font-bold text-white shadow-lg">
|
||||||
|
+{tourInfo.membersCount - 5}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!currentTour) return;
|
||||||
|
if (canEdit) setIsAddMemberOpen(true);
|
||||||
|
}}
|
||||||
|
disabled={!canEdit}
|
||||||
|
className={`p-2 rounded-full border backdrop-blur-sm transition-all ml-2 ${
|
||||||
|
canEdit ? 'bg-white/10 hover:bg-white/20 border-white/20' : 'bg-white/5 border-white/10 opacity-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Financial Quick-View Widget or Quote */}
|
||||||
<div className="max-w-2xl mx-auto -mt-12 px-4 pb-24">
|
<div className="max-w-2xl mx-auto -mt-10 px-4 relative z-10">
|
||||||
|
<div className={`rounded-3xl p-6 shadow-2xl transition-all duration-500 ${hasFinanceAccess ? 'bg-indigo-600 text-white shadow-indigo-200' : 'bg-white text-gray-600 border border-gray-100'}`}>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
{hasFinanceAccess ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<p className="text-indigo-100 text-xs font-black uppercase tracking-widest mb-1">Tổng chi tiêu hiện tại</p>
|
||||||
|
<h3 className="text-3xl font-black">{tourInfo.budget}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-white/10 rounded-2xl backdrop-blur-md"><Wallet className="w-8 h-8" /></div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start gap-4 py-2">
|
||||||
|
<Quote className="w-8 h-8 text-blue-500 opacity-30 flex-shrink-0" />
|
||||||
|
<p className="italic text-lg font-medium leading-relaxed">"{randomQuote}"</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Khối hiển thị Điểm đầu & Điểm cuối (Dưới Financial Quick-View) */}
|
||||||
|
<div className="max-w-2xl mx-auto mt-4 px-4 grid grid-cols-1 sm:grid-cols-2 gap-3 animate-in fade-in slide-in-from-top-2 duration-500">
|
||||||
|
{startPoint && (
|
||||||
|
<div className="bg-white p-4 rounded-2xl border border-blue-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center text-blue-600 shadow-inner">
|
||||||
|
<MapPin className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<p className="text-[10px] font-black text-blue-400 uppercase tracking-widest mb-0.5">Điểm xuất phát</p>
|
||||||
|
<p className="text-sm font-bold text-gray-800 truncate">{startPoint.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{endPoint && (
|
||||||
|
<div className="bg-white p-4 rounded-2xl border border-green-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center text-green-600 shadow-inner">
|
||||||
|
<Flag className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<p className="text-[10px] font-black text-green-400 uppercase tracking-widest mb-0.5">Điểm kết thúc</p>
|
||||||
|
<p className="text-sm font-bold text-gray-800 truncate">{endPoint.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content Area - Điều chỉnh max-width khi ở chế độ bản đồ */}
|
||||||
|
<div className={`${activeTab === 'plan' && viewMode === 'map' ? 'w-full' : 'max-w-2xl mx-auto'} mt-6 px-4 pb-24`}>
|
||||||
{/* Tab Switcher */}
|
{/* Tab Switcher */}
|
||||||
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-1 flex mb-6">
|
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-1 flex mb-6 sticky top-20 z-20">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
@@ -86,10 +479,10 @@ export const TourDetailPage = () => {
|
|||||||
className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${
|
className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'bg-blue-50 text-blue-600 shadow-sm'
|
? 'bg-blue-50 text-blue-600 shadow-sm'
|
||||||
: 'text-gray-400 hover:text-gray-600'
|
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<tab.icon className={`w-4 h-4 mr-2 ${activeTab === tab.id ? 'animate-pulse' : ''}`} />
|
<tab.icon className={`w-4 h-4 mr-2 ${activeTab === tab.id ? 'scale-110' : ''}`} />
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -98,8 +491,84 @@ export const TourDetailPage = () => {
|
|||||||
{/* Tab Panels */}
|
{/* Tab Panels */}
|
||||||
<div className="transition-opacity duration-300">
|
<div className="transition-opacity duration-300">
|
||||||
{activeTab === 'plan' && (
|
{activeTab === 'plan' && (
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-4">
|
<div className="animate-in fade-in slide-in-from-bottom-2">
|
||||||
<ItineraryTimeline />
|
{/* View Mode Toggle */}
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<div className="bg-gray-100 p-1 rounded-2xl flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('timeline')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'timeline' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
|
<List className="w-3.5 h-3.5" /> Danh sách
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('map')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'map' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
|
<MapIconLucide className="w-3.5 h-3.5" /> Bản đồ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewMode === 'timeline' ? (
|
||||||
|
<ItineraryTimeline onAddLocation={(legId) => {
|
||||||
|
setTargetLegId(legId);
|
||||||
|
setEditingLocation(null);
|
||||||
|
setIsAddLocationOpen(true);
|
||||||
|
}} onEditLocation={(loc) => {
|
||||||
|
setEditingLocation(loc);
|
||||||
|
setTargetLegId(loc.legId);
|
||||||
|
setMapCenter([loc.latitude, loc.longitude]);
|
||||||
|
setIsAddLocationOpen(true);
|
||||||
|
}} />
|
||||||
|
) : (
|
||||||
|
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative">
|
||||||
|
<MapContainer
|
||||||
|
center={initialViewState?.center || mapCenter}
|
||||||
|
zoom={mapZoom}
|
||||||
|
className="h-full w-full"
|
||||||
|
preferCanvas={true}
|
||||||
|
>
|
||||||
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||||
|
{canEdit && <MapContextMenu onAction={handleMapAction} />}
|
||||||
|
|
||||||
|
{/* Tự động đóng khung Start và End khi dữ liệu thay đổi */}
|
||||||
|
<MapTourBounds locations={allLocations} />
|
||||||
|
|
||||||
|
{/* Vẽ đường Polyline nối các điểm - Liên tục toàn bộ lộ trình xuyên suốt các chặng */}
|
||||||
|
{allLocations.length > 1 && (
|
||||||
|
<Polyline
|
||||||
|
positions={allLocations.map(l => [l.latitude, l.longitude]) as any}
|
||||||
|
color="#3b82f6"
|
||||||
|
weight={3}
|
||||||
|
dashArray="5, 10"
|
||||||
|
smoothFactor={1.5}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MarkerClusterGroup chunkedLoading>
|
||||||
|
{legs.flatMap(l => l.locations).map((loc: any) => {
|
||||||
|
const isStart = startPoint?.id === loc.id;
|
||||||
|
const isEnd = endPoint?.id === loc.id;
|
||||||
|
// Sử dụng các icon tĩnh đã định nghĩa ở trên
|
||||||
|
const icon = isStart ? START_ICON : isEnd ? END_ICON : VISIT_ICON;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Marker key={loc.id} position={[loc.latitude, loc.longitude]} icon={icon}>
|
||||||
|
<Popup>
|
||||||
|
<div className="font-bold">{loc.name}</div>
|
||||||
|
<div className="text-xs text-gray-500">{loc.type}</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</MarkerClusterGroup>
|
||||||
|
</MapContainer>
|
||||||
|
<div className="absolute top-4 left-4 z-[1000] bg-white/90 backdrop-blur-md p-3 rounded-2xl text-[10px] font-bold text-gray-500 shadow-lg border border-white">
|
||||||
|
Mẹo: Nhấn giữ (Mobile) hoặc Chuột phải để ghim địa điểm
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -110,28 +579,63 @@ export const TourDetailPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'photo' && (
|
{activeTab === 'photo' && (
|
||||||
<div className="grid grid-cols-3 gap-2 animate-in fade-in">
|
<div className="grid grid-cols-3 gap-1.5 animate-in fade-in">
|
||||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
<div key={i} className="aspect-square bg-gray-100 rounded-lg overflow-hidden relative group">
|
<div key={i} className="aspect-square bg-gray-200 rounded-xl overflow-hidden relative group border border-white">
|
||||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
||||||
<img
|
<img
|
||||||
src={`https://picsum.photos/seed/${i + 10}/400/400`}
|
src={`https://picsum.photos/seed/${i + 10}/400/400`}
|
||||||
alt="Tour photo"
|
alt="Tour photo"
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'settings' && (
|
||||||
|
<div className="p-8 text-center bg-white rounded-3xl border border-dashed border-gray-200 animate-in zoom-in-95">
|
||||||
|
<Settings className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-500 font-medium">Tính năng quản lý thành viên đang được cập nhật...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating Action Button (Mobile) */}
|
{/* Floating Action Button (Mobile) */}
|
||||||
|
{canEdit && (
|
||||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
|
||||||
<button className="bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold">
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTargetLegId(null); // Reset khi nhấn nút floating tổng quát
|
||||||
|
setEditingLocation(null);
|
||||||
|
if (activeTab === 'plan') setIsAddLocationOpen(true);
|
||||||
|
}}
|
||||||
|
className="bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold">
|
||||||
{activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh'}
|
{activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Member Modal */}
|
||||||
|
{currentTour && (
|
||||||
|
<AddMemberModal
|
||||||
|
isOpen={isAddMemberOpen}
|
||||||
|
onClose={() => setIsAddMemberOpen(false)}
|
||||||
|
tourId={currentTour.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Location Modal */}
|
||||||
|
{currentTour && (
|
||||||
|
<AddLocationModal
|
||||||
|
isOpen={isAddLocationOpen}
|
||||||
|
onClose={() => setIsAddLocationOpen(false)}
|
||||||
|
initialLegId={targetLegId || undefined}
|
||||||
|
editingLocation={editingLocation}
|
||||||
|
tourId={currentTour.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# KẾ HOẠCH PHÁT TRIỂN TÍNH NĂNG ĐỊNH VỊ LỘ TRÌNH (AGENT SPECIFICATION)
|
||||||
|
|
||||||
|
## 1. Phân Tích Trạng Thái Giao Diện Hiện Tại (Context Analysis)
|
||||||
|
* **Màn hình**: Tour Dashboard (Tên tour người dùng tự đặt khi tạo tour mới).
|
||||||
|
* **Tab Active**: Lộ trình (Mặc định).
|
||||||
|
* **Sub-view Active**: Bản đồ (Component bản đồ đang hiển thị ở nửa dưới màn hình).
|
||||||
|
* **Thành phần cần can thiệp**: Lớp tương tác (Interaction Layer) của thư viện Bản đồ (Mapbox / Google Maps API / Leaflet) đang render phía dưới.
|
||||||
|
│
|
||||||
|
|
||||||
|
## 2. Đặc Tả Kỹ Thuật Đóng Gói (Functional Requirements)
|
||||||
|
|
||||||
|
### 2.1. Quản lý Trạng thái Thực thể (Data State Management)
|
||||||
|
Agent cần ánh xạ sự kiện trên UI vào hai trường dữ liệu trong Database Schema:
|
||||||
|
* **Điểm xuất phát (StartLocation)**: Ánh xạ vào Location đầu tiên của Tour với sequence: 0 hoặc cấu hình tọa độ trực tiếp vào cấu trúc metadata của Tour.
|
||||||
|
* **Điểm kết thúc (EndLocation)**: Ánh xạ vào Location cuối cùng của Tour hoặc trường đích của Tour.
|
||||||
|
|
||||||
|
### 2.2. Chi Tiết Bản Ghép Logic Sự Kiện (Event Mapping)
|
||||||
|
|
||||||
|
| Thao tác Người dùng | Trình kích hoạt (Trigger) | Hành động Hệ thống (System Action) | Phản hồi Giao diện (UI Feedback) |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **Xác định Điểm xuất phát** | - Tìm kiếm trên Map Search Box<br>- Hoặc Right-Click (Desktop)<br>- Hoặc Long-Press (Mobile) | 1. Trích xuất tọa độ (lat, lng).<br>2. Gọi API khởi tạo điểm đầu.<br>3. Cập nhật nhãn "Điểm xuất phát:..." trên Top Banner. | Tạo 1 Ghim (Marker) Màu Xanh Blue tại tọa độ được chọn. |
|
||||||
|
| **Xác định Điểm kết thúc** | - Tìm kiếm trên Map Search Box<br>- Hoặc Right-Click (Desktop)<br>- Hoặc Long-Press (Mobile) | 1. Trích xuất tọa độ (lat, lng).<br>2. Gọi API khởi tạo điểm cuối.<br>3. Cập nhật nhãn "Điểm kết thúc:..." trên Top Banner. | Tạo 1 Ghim (Marker) Màu Xanh Green tại tọa độ được chọn. |
|
||||||
|
|
||||||
|
## 3. Kiến Trúc Mã Nguồn Gợi Ý Cho AI Agent (Pseudocode / Implementation Guide)
|
||||||
|
Agent cần triển khai component bản đồ với cấu trúc xử lý sự kiện (Context Menu) như sau:
|
||||||
|
|
||||||
|
### 3.1. Cấu trúc Menu Ngữ Cảnh (Custom Context Menu Component)
|
||||||
|
Khi sự kiện click chuột phải/nhấn giữ xảy ra, lấy ra tọa độ (lat, lng) của điểm chạm và hiển thị menu pop-up tại vị trí con trỏ:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ContextMenuProps {
|
||||||
|
x: number; // Tọa độ pixel trên màn hình
|
||||||
|
y: number;
|
||||||
|
latLng: { lat: number; lng: number };
|
||||||
|
onSelectStart: (latLng: { lat: number; lng: number }) => void;
|
||||||
|
onSelectEnd: (latLng: { lat: number; lng: number }) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2. Thuật toán xử lý ghim (Marker Rendering Logic)
|
||||||
|
Mô tả logic bằng mã giả để Agent tiến hành sinh code xử lý:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Trạng thái lưu trữ tọa độ trên Frontend
|
||||||
|
const [startCoords, setStartCoords] = useState(null);
|
||||||
|
const [endCoords, setEndCoords] = useState(null);
|
||||||
|
|
||||||
|
// Hàm xử lý khi chọn "Bắt đầu từ đây"
|
||||||
|
function handleSetStartPoint(latLng) {
|
||||||
|
// 1. Cập nhật state để render ghim Blue
|
||||||
|
setStartCoords(latLng);
|
||||||
|
|
||||||
|
// 2. Gọi API cập nhật Database thông qua UUID của Tour hiện tại
|
||||||
|
API.updateTourStartPoint(tourId, {
|
||||||
|
latitude: latLng.lat,
|
||||||
|
longitude: latLng.lng,
|
||||||
|
locationType: 'START'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Render Marker Blue lên Bản đồ
|
||||||
|
map.renderMarker({
|
||||||
|
position: latLng,
|
||||||
|
icon: 'blue-pin.png',
|
||||||
|
label: 'S'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hàm xử lý khi chọn "Kết thúc ở đây"
|
||||||
|
function handleSetEndPoint(latLng) {
|
||||||
|
// 1. Cập nhật state để render ghim Green
|
||||||
|
setEndCoords(latLng);
|
||||||
|
|
||||||
|
// 2. Gọi API cập nhật Database
|
||||||
|
API.updateTourEndPoint(tourId, {
|
||||||
|
latitude: latLng.lat,
|
||||||
|
longitude: latLng.lng,
|
||||||
|
locationType: 'END'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Render Marker Green lên Bản đồ
|
||||||
|
map.renderMarker({
|
||||||
|
position: latLng,
|
||||||
|
icon: 'green-pin.png',
|
||||||
|
label: 'E'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -32,7 +32,7 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
|
|||||||
if (isOpen) fetchUsers();
|
if (isOpen) fetchUsers();
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const handleToggleBlock = async (id: number) => {
|
const handleToggleBlock = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
await fetch(`${API_BASE}/api/v1/users/block/${id}`, {
|
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;
|
if (!confirm('Bạn có chắc chắn muốn xóa người dùng này?')) return;
|
||||||
try {
|
try {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
|||||||
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
+8
@@ -0,0 +1,8 @@
|
|||||||
|
import React from 'react';
|
||||||
|
interface AddMemberModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
tourId: string;
|
||||||
|
}
|
||||||
|
export declare const AddMemberModal: React.FC<AddMemberModalProps>;
|
||||||
|
export {};
|
||||||
Vendored
+80
@@ -0,0 +1,80 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { X, Search, UserPlus, Loader2, Shield } from 'lucide-react';
|
||||||
|
export const AddMemberModal = ({ isOpen, onClose, tourId }) => {
|
||||||
|
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 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(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('');
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!selectedUser)
|
||||||
|
return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ userId: selectedUser, role }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || 'Thêm thành viên thất bại');
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
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" }), " Th\u00EAm th\u00E0nh vi\u00EAn"] }), _jsx("p", { className: "text-xs text-gray-500", children: "Ch\u1ECDn ng\u01B0\u1EDDi d\u00F9ng v\u00E0 ph\u00E2n quy\u1EC1n cho tour n\u00E0y." })] }), _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", { className: "relative", children: [_jsx(Search, { className: "absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" }), _jsx("input", { className: "w-full pl-9 pr-4 py-2.5 bg-gray-50 border border-gray-100 rounded-xl outline-none text-sm", placeholder: "T\u00ECm theo t\u00EAn ho\u1EB7c email...", value: query, onChange: (e) => setQuery(e.target.value), onBlur: fetchUsers })] }), _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 })), 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: [users.map((u) => {
|
||||||
|
const isSelected = selectedUser === u.id;
|
||||||
|
return (_jsxs("button", { onClick: () => setSelectedUser(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'}`, 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 && users.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 thêm...' : 'Thêm vào tour' })] })] })] }));
|
||||||
|
};
|
||||||
|
//# 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 { TourDetailPage } from './TourDetailPage.js';
|
||||||
import { ExploreMap } from './ExploreMap.js';
|
import { ExploreMap } from './ExploreMap.js';
|
||||||
import { SignupPage } from './SignupPage.js';
|
import { SignupPage } from './SignupPage.js';
|
||||||
|
import { useTourStore } from './useTourStore.js';
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const [view, setView] = useState('landing');
|
const [view, setView] = useState('landing');
|
||||||
const [isInitialSetup, setIsInitialSetup] = useState(false);
|
const [isInitialSetup, setIsInitialSetup] = useState(false);
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [isUserLoaded, setIsUserLoaded] = useState(false);
|
const [isUserLoaded, setIsUserLoaded] = useState(false);
|
||||||
|
const fetchTour = useTourStore(state => state.fetchTour);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
const savedUser = localStorage.getItem('user');
|
const savedUser = localStorage.getItem('user');
|
||||||
@@ -36,7 +38,10 @@ const App = () => {
|
|||||||
setUser(null);
|
setUser(null);
|
||||||
setView('landing');
|
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;
|
export default App;
|
||||||
//# sourceMappingURL=App.js.map
|
//# sourceMappingURL=App.js.map
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"App.js","sourceRoot":"","sources":["../App.tsx"],"names":[],"mappings":";AAAA,OAAc,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACnD,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,MAAM,GAAG,GAAG,GAAG,EAAE;IAEf,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAO,SAAS,CAAC,CAAC;IAClD,MAAM,CAAC,cAAc,EAAE,iBAAiB,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;IAC5D,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAM,IAAI,CAAC,CAAC;IAC5C,MAAM,CAAC,YAAY,EAAE,eAAe,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;IAExD,SAAS,CAAC,GAAG,EAAE;QAEb,MAAM,QAAQ,GAAG,UAAU,MAAM,CAAC,QAAQ,CAAC,QAAQ,OAAO,CAAC;QAG3D,MAAM,SAAS,GAAG,YAAY,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QAC/C,IAAI,SAAS,EAAE,CAAC;YACd,MAAM,UAAU,GAAG,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;YACzC,OAAO,CAAC,UAAU,CAAC,CAAC;QACtB,CAAC;QACD,eAAe,CAAC,IAAI,CAAC,CAAC;QAGtB,KAAK,CAAC,GAAG,QAAQ,qBAAqB,CAAC;aACpC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;aACnD,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,iBAAiB,CAAC,CAAC,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;aACtD,KAAK,CAAC,GAAG,EAAE,CAAC,iBAAiB,CAAC,KAAK,CAAC,CAAC,CAAC;IAC3C,CAAC,EAAE,EAAE,CAAC,CAAC;IAGP,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,YAAY,IAAI,IAAI,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;YAC/C,OAAO,CAAC,SAAS,CAAC,CAAC;QACrB,CAAC;IACH,CAAC,EAAE,CAAC,YAAY,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC;IAE/B,MAAM,kBAAkB,GAAG,CAAC,QAAa,EAAE,EAAE;QAC3C,OAAO,CAAC,QAAQ,CAAC,CAAC;IACpB,CAAC,CAAC;IAEF,MAAM,YAAY,GAAG,GAAG,EAAE;QACxB,YAAY,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;QAChC,YAAY,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC;QACjC,OAAO,CAAC,IAAI,CAAC,CAAC;QACd,OAAO,CAAC,SAAS,CAAC,CAAC;IACrB,CAAC,CAAC;IAEF,OAAO,CACL,eAAK,SAAS,EAAC,eAAe,aAC3B,IAAI,KAAK,SAAS,IAAI,CACrB,KAAC,WAAW,IACV,cAAc,EAAE,cAAc,EAC9B,UAAU,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC,EACpC,YAAY,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,QAAQ,CAAC,EACrC,SAAS,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC,EACnC,cAAc,EAAE,kBAAkB,GAClC,CACH,EAEA,IAAI,KAAK,QAAQ,IAAI,CACpB,KAAC,UAAU,IACT,MAAM,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC,EAChC,SAAS,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC,GACnC,CACH,EAEA,IAAI,KAAK,SAAS,IAAI,CACrB,KAAC,UAAU,IACT,MAAM,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC,EAChC,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,SAAS,EACzC,IAAI,EAAE,IAAI,GACV,CACH,EAEA,IAAI,KAAK,QAAQ,IAAI,CACpB,KAAC,cAAc,KAAG,CACnB,IACG,CACP,CAAC;AACJ,CAAC,CAAC;AAEF,eAAe,GAAG,CAAC"}
|
{"version":3,"file":"App.js","sourceRoot":"","sources":["../App.tsx"],"names":[],"mappings":"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"}
|
||||||
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
+30
@@ -0,0 +1,30 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { X, Map as MapIcon, Loader2 } 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);
|
||||||
|
if (!isOpen)
|
||||||
|
return null;
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const tour = await createTour({ title, startDate, endDate });
|
||||||
|
onSuccess(tour);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
alert('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-8", children: [_jsxs("div", { className: "flex justify-between items-center mb-6", children: [_jsxs("h2", { className: "text-2xl font-bold text-gray-900 flex items-center gap-2", children: [_jsx(MapIcon, { className: "w-6 h-6 text-blue-600" }), " T\u1EA1o Tour m\u1EDBi"] }), _jsx("button", { onClick: onClose, className: "p-2 hover:bg-gray-100 rounded-full transition-colors", children: _jsx(X, { className: "w-6 h-6 text-gray-400" }) })] }), _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) })] })] }), _jsx("button", { disabled: isLoading, className: "w-full py-4 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 ? _jsx(Loader2, { className: "w-5 h-5 animate-spin" }) : 'Xác nhận tạo Tour' })] })] })] }));
|
||||||
|
};
|
||||||
|
//# sourceMappingURL=CreateTourModal.js.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"CreateTourModal.js","sourceRoot":"","sources":["../CreateTourModal.tsx"],"names":[],"mappings":"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"}
|
||||||
Vendored
+2
-1
@@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
export declare const ExploreMap: ({ onBack, onLogout, user }: {
|
export declare const ExploreMap: ({ onBack, onLogout, user, onViewTour }: {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
user?: any;
|
user?: any;
|
||||||
|
onViewTour: (id: string) => void;
|
||||||
}) => React.JSX.Element;
|
}) => React.JSX.Element;
|
||||||
|
|||||||
Vendored
+61
-15
@@ -1,11 +1,14 @@
|
|||||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
import { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
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 L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { useTourStore } from './useTourStore.js';
|
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 { UserManagementModal } from './UserManagementModal.js';
|
||||||
|
import { CreateTourModal } from './CreateTourModal.js';
|
||||||
const DefaultIcon = L.icon({
|
const DefaultIcon = L.icon({
|
||||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.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',
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||||
@@ -20,33 +23,76 @@ function RecenterMap({ position }) {
|
|||||||
}, [position, map]);
|
}, [position, map]);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
export const ExploreMap = ({ onBack, onLogout, user }) => {
|
function MapTracker() {
|
||||||
const { publicTours, fetchPublicTours } = useTourStore();
|
const setMapCenter = useTourStore(state => state.setMapCenter);
|
||||||
const [userPos, setUserPos] = useState([10.7769, 106.7009]);
|
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 [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPublicTours();
|
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) => {
|
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 place = tour.legs?.[0]?.places?.[0];
|
const startLoc = tour.legs?.[0]?.locations?.[0];
|
||||||
if (!place)
|
if (!startLoc)
|
||||||
return null;
|
return null;
|
||||||
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
|
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
|
||||||
return (_jsx(Marker, { position: [place.latitude, place.longitude], icon: L.divIcon({
|
return (_jsx(React.Fragment, { children: _jsx(Marker, { position: [startLoc.latitude, startLoc.longitude], eventHandlers: {
|
||||||
|
click: () => onViewTour(tour.id)
|
||||||
|
}, icon: L.divIcon({
|
||||||
className: 'custom-bubble',
|
className: 'custom-bubble',
|
||||||
html: `
|
html: `
|
||||||
<div class="relative group">
|
<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">
|
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
|
||||||
<img src="${tourImage}" class="w-full h-full object-cover" />
|
<img src="${tourImage}" class="w-full h-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full p-1 border-2 border-white">
|
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
|
S
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
iconSize: [48, 48],
|
iconSize: [48, 48],
|
||||||
}), 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));
|
iconAnchor: [24, 24]
|
||||||
})] }), _jsx(UserManagementModal, { isOpen: isAdminModalOpen, onClose: () => setIsAdminModalOpen(false) })] }));
|
}) }) }, 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
|
//# 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';
|
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
+84
-7
@@ -1,6 +1,6 @@
|
|||||||
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 { format, differenceInMinutes, parseISO } from 'date-fns';
|
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
||||||
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle } from 'lucide-react';
|
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List } from 'lucide-react';
|
||||||
import { useTourStore } from './useTourStore.js';
|
import { useTourStore } from './useTourStore.js';
|
||||||
const TimeVariance = ({ planned, actual }) => {
|
const TimeVariance = ({ planned, actual }) => {
|
||||||
if (!actual)
|
if (!actual)
|
||||||
@@ -9,11 +9,88 @@ const TimeVariance = ({ planned, actual }) => {
|
|||||||
const isLate = diff > 0;
|
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` })] }));
|
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 calculateDistance = (lat1, lon1, lat2, lon2) => {
|
||||||
const { legs } = useTourStore();
|
const p = 0.017453292519943295;
|
||||||
const toggleComplete = async (placeId) => {
|
const c = Math.cos;
|
||||||
console.log("Toggle status for place:", placeId);
|
const a = 0.5 - c((lat2 - lat1) * p) / 2 +
|
||||||
|
c(lat1 * p) * c(lat2 * p) *
|
||||||
|
(1 - c((lon2 - lon1) * p)) / 2;
|
||||||
|
return 12742 * Math.asin(Math.sqrt(a));
|
||||||
|
};
|
||||||
|
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 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) => {
|
||||||
|
if (window.confirm("Bạn có chắc chắn muốn xóa chặng này?")) {
|
||||||
|
try {
|
||||||
|
await deleteLeg(legId);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleDeleteLocation = async (id) => {
|
||||||
|
if (window.confirm("Bạn có chắc chắn muốn xóa địa điểm này?")) {
|
||||||
|
try {
|
||||||
|
await deleteLocation(id);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (_jsx("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"] })] }))] }) }));
|
||||||
};
|
};
|
||||||
//# sourceMappingURL=ItineraryTimeline.js.map
|
//# 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';
|
import React from 'react';
|
||||||
export declare const TourDetailPage: () => React.JSX.Element;
|
export declare const TourDetailPage: ({ onBack }: {
|
||||||
|
onBack: () => void;
|
||||||
|
}) => React.JSX.Element;
|
||||||
|
|||||||
Vendored
+258
-22
@@ -1,33 +1,269 @@
|
|||||||
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, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { ItineraryTimeline } from './ItineraryTimeline.js';
|
import { ItineraryTimeline } from './ItineraryTimeline.js';
|
||||||
import { ExpenseManager } from './ExpenseManager.js';
|
import { ExpenseManager } from './ExpenseManager.js';
|
||||||
import { useTourStore } from './useTourStore.js';
|
import { useTourStore } from './useTourStore.js';
|
||||||
import { Map as MapIcon, Wallet, Image as ImageIcon, Calendar, Users, ChevronLeft } from 'lucide-react';
|
import { AddLocationModal } from './AddLocationModal.js';
|
||||||
export const TourDetailPage = () => {
|
import { AddMemberModal } from './AddMemberModal.js';
|
||||||
const [activeTab, setActiveTab] = useState('plan');
|
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline } from 'react-leaflet';
|
||||||
const { currentTour, fetchTour, userRole } = useTourStore();
|
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 } 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(() => {
|
useEffect(() => {
|
||||||
fetchTour(1);
|
if (locations.length > 0) {
|
||||||
}, [fetchTour]);
|
const bounds = L.latLngBounds(locations.map(l => [l.latitude, l.longitude]));
|
||||||
const tabs = [
|
if (locations.length === 1) {
|
||||||
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: ['OWNER', 'EDITOR', 'MEMBER_PLAN_ONLY'].includes(userRole || '') },
|
map.setView([locations[0].latitude, locations[0].longitude], 15, { animate: true });
|
||||||
{ 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');
|
|
||||||
}
|
}
|
||||||
}, [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 [initialViewState] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('map_view_state');
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(saved);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const { currentTour, legs, fetchTour, fetchPublicTours, publicTours, userRole, mapCenter, setMapCenter, updateTourStartPoint, updateTourEndPoint, initializeLegs, addLocation, addMember } = useTourStore();
|
||||||
|
const canEdit = ['OWNER', 'MANAGER'].includes(userRole || '');
|
||||||
|
const [mapZoom] = useState(initialViewState?.zoom || 13);
|
||||||
|
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialViewState) {
|
||||||
|
setMapCenter(initialViewState.center);
|
||||||
|
}
|
||||||
|
const loadData = async () => {
|
||||||
|
if (publicTours.length === 0) {
|
||||||
|
await fetchPublicTours();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (publicTours.length > 0 && !currentTour) {
|
||||||
|
fetchTour(publicTours[0].id);
|
||||||
|
}
|
||||||
|
}, [publicTours, currentTour, fetchTour]);
|
||||||
|
const handleDeclareLegs = async () => {
|
||||||
|
const countStr = window.prompt("Chuyến đi này bạn muốn chia làm bao nhiêu chặng?", "3");
|
||||||
|
const count = parseInt(countStr || "0");
|
||||||
|
if (count > 0 && currentTour) {
|
||||||
|
await initializeLegs(currentTour.id, count);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleMapAction = async (action, latlng) => {
|
||||||
|
console.log(`[FRONTEND] Triggered ${action} at:`, { lat: latlng.lat, lng: latlng.lng });
|
||||||
|
const currentLegs = useTourStore.getState().legs;
|
||||||
|
if (!currentTour || currentLegs.length === 0) {
|
||||||
|
alert("Tour chưa có chặng nào. Vui lòng tạo chặng (Leg) trước khi thực hiện.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let targetLegId = currentLegs[0].id;
|
||||||
|
let defaultName = "Địa điểm mới";
|
||||||
|
let locationType = 'VISIT';
|
||||||
|
if (action === 'START') {
|
||||||
|
defaultName = "Điểm bắt đầu";
|
||||||
|
locationType = 'MOVE';
|
||||||
|
}
|
||||||
|
if (action === 'END') {
|
||||||
|
targetLegId = currentLegs[currentLegs.length - 1].id;
|
||||||
|
defaultName = "Điểm kết thúc";
|
||||||
|
locationType = 'MOVE';
|
||||||
|
}
|
||||||
|
if (action.startsWith('ADD_TO_LEG_')) {
|
||||||
|
targetLegId = action.replace('ADD_TO_LEG_', '');
|
||||||
|
}
|
||||||
|
let detectedName = "";
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latlng.lat}&lon=${latlng.lng}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const addr = data.address;
|
||||||
|
detectedName = addr.amenity || addr.building || addr.historic || addr.tourist ||
|
||||||
|
addr.shop || addr.office || addr.leisure || addr.attraction ||
|
||||||
|
addr.road || addr.neighbourhood || addr.suburb ||
|
||||||
|
data.display_name?.split(',')[0] || "";
|
||||||
|
console.log(`[FRONTEND] Geocoding Result: "${detectedName}"`);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.warn("[FRONTEND] Reverse Geocoding failed:", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (action === 'START') {
|
||||||
|
const resolvedPlaceName = detectedName || "Điểm xuất phát";
|
||||||
|
console.log(`[FRONTEND] Updating START point to: ${resolvedPlaceName} at`, latlng);
|
||||||
|
await updateTourStartPoint(currentTour.id, {
|
||||||
|
name: resolvedPlaceName,
|
||||||
|
latitude: latlng.lat,
|
||||||
|
longitude: latlng.lng,
|
||||||
|
});
|
||||||
|
console.log("[FRONTEND] START point updated successfully.");
|
||||||
|
}
|
||||||
|
else if (action === 'END') {
|
||||||
|
const finalName = detectedName || "Điểm kết thúc";
|
||||||
|
await updateTourEndPoint(currentTour.id, {
|
||||||
|
name: finalName,
|
||||||
|
latitude: latlng.lat,
|
||||||
|
longitude: latlng.lng,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const name = window.prompt("Xác nhận tên địa điểm tham quan:", detectedName || defaultName);
|
||||||
|
if (!name)
|
||||||
|
return;
|
||||||
|
await addLocation(currentTour.id, {
|
||||||
|
name,
|
||||||
|
address: '',
|
||||||
|
latitude: latlng.lat,
|
||||||
|
longitude: latlng.lng,
|
||||||
|
legId: targetLegId,
|
||||||
|
type: locationType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
if (error.message.includes('Không tìm thấy chặng')) {
|
||||||
|
alert("Lỗi: Bạn cần tạo ít nhất một Chặng (Leg) trước khi xác định điểm Bắt đầu/Kết thúc.");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
alert("Đã xảy ra lỗi: " + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const startPoint = legs[0]?.locations[0];
|
||||||
|
const lastLeg = legs[legs.length - 1];
|
||||||
|
const endPoint = lastLeg?.locations[lastLeg.locations.length - 1];
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: true },
|
||||||
|
{ id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '') },
|
||||||
|
{ id: 'photo', label: 'Ảnh', icon: ImageIcon, visible: true },
|
||||||
|
{ id: 'settings', label: 'Cài đặt', icon: Settings, visible: userRole === 'OWNER' },
|
||||||
|
].filter(t => t.visible);
|
||||||
|
const hasFinanceAccess = ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
||||||
|
const travelQuotes = [
|
||||||
|
"Đừng nghe họ nói, hãy tự mình đi xem.",
|
||||||
|
"Thế giới là một cuốn sách, và ai không đi du lịch thì chỉ mới đọc được một trang.",
|
||||||
|
"Hành trình ngàn dặm bắt đầu từ một bước chân.",
|
||||||
|
"Đi là để trở về, nhưng với một tâm hồn mới."
|
||||||
|
];
|
||||||
|
const randomQuote = useMemo(() => travelQuotes[Math.floor(Math.random() * travelQuotes.length)], []);
|
||||||
const tourInfo = {
|
const tourInfo = {
|
||||||
title: currentTour?.title || "Hành trình khám phá TP.HCM",
|
title: currentTour?.title || "Hành trình khám phá TP.HCM",
|
||||||
date: "20 - 21 Tháng 11, 2023",
|
date: currentTour?.startDate ? `${new Date(currentTour.startDate).toLocaleDateString('vi-VN')} - ${new Date(currentTour.endDate).toLocaleDateString('vi-VN')}` : "Chưa xác định ngày",
|
||||||
members: 5,
|
membersCount: currentTour?.participants?.length || 0,
|
||||||
budget: "2.500.000 VND"
|
budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND",
|
||||||
|
coverImage: currentTour?.photos?.[0]?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
|
||||||
};
|
};
|
||||||
return (_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 -space-x-3", children: [currentTour?.participants?.slice(0, 5).map((p, i) => (_jsx("div", { 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", children: _jsx("img", { src: `https://i.pravatar.cc/100?u=${p.userId}`, alt: "Avatar" }) }, i))), tourInfo.membersCount > 5 && (_jsxs("div", { className: "w-10 h-10 rounded-full border-2 border-white bg-gray-800 flex items-center justify-center text-[10px] font-bold text-white shadow-lg", children: ["+", tourInfo.membersCount - 5] }))] }), _jsx("button", { onClick: () => {
|
||||||
|
if (!currentTour)
|
||||||
|
return;
|
||||||
|
if (canEdit)
|
||||||
|
setIsAddMemberOpen(true);
|
||||||
|
}, disabled: !canEdit, className: `p-2 rounded-full border backdrop-blur-sm transition-all ml-2 ${canEdit ? 'bg-white/10 hover:bg-white/20 border-white/20' : 'bg-white/5 border-white/10 opacity-50'}`, children: _jsx(Plus, { className: "w-4 h-4" }) })] })] }) })] }), _jsx("div", { className: "max-w-2xl mx-auto -mt-10 px-4 relative z-10", children: _jsx("div", { className: `rounded-3xl p-6 shadow-2xl transition-all duration-500 ${hasFinanceAccess ? 'bg-indigo-600 text-white shadow-indigo-200' : 'bg-white text-gray-600 border border-gray-100'}`, children: _jsx("div", { className: "flex justify-between items-center", children: hasFinanceAccess ? (_jsxs(_Fragment, { children: [_jsxs("div", { children: [_jsx("p", { className: "text-indigo-100 text-xs font-black uppercase tracking-widest mb-1", children: "T\u1ED5ng chi ti\u00EAu hi\u1EC7n t\u1EA1i" }), _jsx("h3", { className: "text-3xl font-black", children: tourInfo.budget })] }), _jsx("div", { className: "p-4 bg-white/10 rounded-2xl backdrop-blur-md", children: _jsx(Wallet, { className: "w-8 h-8" }) })] })) : (_jsxs("div", { className: "flex items-start gap-4 py-2", children: [_jsx(Quote, { className: "w-8 h-8 text-blue-500 opacity-30 flex-shrink-0" }), _jsxs("p", { className: "italic text-lg font-medium leading-relaxed", children: ["\"", randomQuote, "\""] })] })) }) }) }), _jsxs("div", { className: "max-w-2xl mx-auto mt-4 px-4 grid grid-cols-1 sm:grid-cols-2 gap-3 animate-in fade-in slide-in-from-top-2 duration-500", children: [startPoint && (_jsxs("div", { className: "bg-white p-4 rounded-2xl border border-blue-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow", children: [_jsx("div", { className: "w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center text-blue-600 shadow-inner", children: _jsx(MapPin, { className: "w-5 h-5" }) }), _jsxs("div", { className: "overflow-hidden", children: [_jsx("p", { className: "text-[10px] font-black text-blue-400 uppercase tracking-widest mb-0.5", children: "\u0110i\u1EC3m xu\u1EA5t ph\u00E1t" }), _jsx("p", { className: "text-sm font-bold text-gray-800 truncate", children: startPoint.name })] })] })), endPoint && (_jsxs("div", { className: "bg-white p-4 rounded-2xl border border-green-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow", children: [_jsx("div", { className: "w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center text-green-600 shadow-inner", children: _jsx(Flag, { className: "w-5 h-5" }) }), _jsxs("div", { className: "overflow-hidden", children: [_jsx("p", { className: "text-[10px] font-black text-green-400 uppercase tracking-widest mb-0.5", children: "\u0110i\u1EC3m k\u1EBFt th\u00FAc" }), _jsx("p", { className: "text-sm font-bold text-gray-800 truncate", children: endPoint.name })] })] }))] }), _jsxs("div", { className: `${activeTab === 'plan' && viewMode === 'map' ? 'w-full' : 'max-w-2xl mx-auto'} mt-6 px-4 pb-24`, children: [_jsx("div", { className: "bg-white rounded-2xl shadow-lg border border-gray-100 p-1 flex mb-6 sticky top-20 z-20", children: tabs.map((tab) => (_jsxs("button", { onClick: () => setActiveTab(tab.id), className: `flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${activeTab === tab.id
|
||||||
? 'bg-blue-50 text-blue-600 shadow-sm'
|
? 'bg-blue-50 text-blue-600 shadow-sm'
|
||||||
: 'text-gray-400 hover:text-gray-600'}`, children: [_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: "p-8 text-center bg-white rounded-3xl border border-dashed border-gray-200 animate-in zoom-in-95", children: [_jsx(Settings, { className: "w-12 h-12 text-gray-300 mx-auto mb-4" }), _jsx("p", { className: "text-gray-500 font-medium", children: "T\u00EDnh n\u0103ng qu\u1EA3n l\u00FD th\u00E0nh vi\u00EAn \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 })), currentTour && (_jsx(AddLocationModal, { isOpen: isAddLocationOpen, onClose: () => setIsAddLocationOpen(false), initialLegId: targetLegId || undefined, editingLocation: editingLocation, tourId: currentTour.id }))] }));
|
||||||
};
|
};
|
||||||
//# sourceMappingURL=TourDetailPage.js.map
|
//# sourceMappingURL=TourDetailPage.js.map
|
||||||
Vendored
+1
-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;
|
private prisma;
|
||||||
constructor(prisma: PrismaService);
|
constructor(prisma: PrismaService);
|
||||||
validate(payload: any): Promise<{
|
validate(payload: any): Promise<{
|
||||||
email: string;
|
|
||||||
id: number;
|
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
address: string | null;
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
phone: string | null;
|
||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|||||||
Vendored
+1
@@ -1 +1,2 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
|||||||
Vendored
+559
-33
@@ -10,15 +10,16 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|||||||
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
||||||
return function (target, key) { decorator(target, key, paramIndex); }
|
return function (target, key) { decorator(target, key, paramIndex); }
|
||||||
};
|
};
|
||||||
|
import 'reflect-metadata';
|
||||||
import { NestFactory } from '@nestjs/core';
|
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 } from '@nestjs/common';
|
||||||
import { PrismaService } from './prisma.service.js';
|
import { PrismaService } from './prisma.service.js';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { AdminGuard } from './admin.guard.js';
|
|
||||||
import { JwtModule, JwtService } from '@nestjs/jwt';
|
import { JwtModule, JwtService } from '@nestjs/jwt';
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard.js';
|
import { JwtAuthGuard } from './jwt-auth.guard.js';
|
||||||
import { JwtStrategy } from './jwt.strategy.js';
|
import { JwtStrategy } from './jwt.strategy.js';
|
||||||
|
import { TourRoleGuard } from './rbac.middleware.js';
|
||||||
let AppController = class AppController {
|
let AppController = class AppController {
|
||||||
getHello() {
|
getHello() {
|
||||||
return 'Travel Planning API is running!';
|
return 'Travel Planning API is running!';
|
||||||
@@ -64,7 +65,7 @@ let AuthController = class AuthController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
async signup(body) {
|
async signup(body) {
|
||||||
const { email, password, name } = body;
|
const { email, password, name, phone, address } = body;
|
||||||
const existingUser = await this.prisma.user.findUnique({ where: { email } });
|
const existingUser = await this.prisma.user.findUnique({ where: { email } });
|
||||||
if (existingUser)
|
if (existingUser)
|
||||||
throw new BadRequestException('Email đã được sử dụng');
|
throw new BadRequestException('Email đã được sử dụng');
|
||||||
@@ -72,8 +73,8 @@ let AuthController = class AuthController {
|
|||||||
const shouldBeAdmin = userCount === 0;
|
const shouldBeAdmin = userCount === 0;
|
||||||
const passwordHash = await bcrypt.hash(password, 10);
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
return this.prisma.user.create({
|
return this.prisma.user.create({
|
||||||
data: { email, passwordHash, name, isAdmin: shouldBeAdmin },
|
data: { email, passwordHash, name, phone, address, isAdmin: shouldBeAdmin },
|
||||||
select: { id: true, email: true, name: true, isAdmin: true }
|
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -105,15 +106,195 @@ let TourController = class TourController {
|
|||||||
constructor(prisma) {
|
constructor(prisma) {
|
||||||
this.prisma = 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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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({
|
return this.prisma.tour.findMany({
|
||||||
|
where: {
|
||||||
|
participants: {
|
||||||
|
some: { userId: req.user.id }
|
||||||
|
}
|
||||||
|
},
|
||||||
take: 20,
|
take: 20,
|
||||||
include: {
|
include: {
|
||||||
photos: { take: 1 },
|
photos: { take: 1 },
|
||||||
legs: {
|
legs: {
|
||||||
take: 1,
|
orderBy: { sequence: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
places: { take: 1 }
|
locations: { orderBy: { plannedStart: 'asc' } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,13 +304,18 @@ let TourController = class TourController {
|
|||||||
const tour = await this.prisma.tour.findUnique({
|
const tour = await this.prisma.tour.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
members: true,
|
participants: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: { id: true, name: true, email: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
photos: true,
|
photos: true,
|
||||||
legs: {
|
legs: {
|
||||||
orderBy: { sequenceNumber: 'asc' },
|
orderBy: { sequence: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
places: { orderBy: { sequenceInLeg: 'asc' } },
|
locations: { orderBy: { plannedStart: 'asc' } },
|
||||||
expenses: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -138,32 +324,366 @@ let TourController = class TourController {
|
|||||||
throw new NotFoundException(`Không tìm thấy Tour với ID ${id}`);
|
throw new NotFoundException(`Không tìm thấy Tour với ID ${id}`);
|
||||||
return tour;
|
return tour;
|
||||||
}
|
}
|
||||||
|
async addMember(tourId, body, req) {
|
||||||
|
const validRoles = ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY'];
|
||||||
|
const role = validRoles.includes(body.role) ? body.role : 'MEMBER';
|
||||||
|
const participation = await this.prisma.tourParticipant.findUnique({
|
||||||
|
where: { tourId_userId: { tourId, userId: body.userId } },
|
||||||
|
});
|
||||||
|
if (participation) {
|
||||||
|
return this.prisma.tourParticipant.update({
|
||||||
|
where: { tourId_userId: { tourId, userId: body.userId } },
|
||||||
|
data: { role },
|
||||||
|
include: { user: { select: { id: true, name: true, email: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.prisma.tourParticipant.create({
|
||||||
|
data: {
|
||||||
|
tourId,
|
||||||
|
userId: body.userId,
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
include: { user: { select: { id: true, name: true, email: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
__decorate([
|
__decorate([
|
||||||
Get('explore'),
|
UseGuards(JwtAuthGuard),
|
||||||
|
Post(),
|
||||||
|
__param(0, Body()),
|
||||||
|
__param(1, Req()),
|
||||||
__metadata("design:type", Function),
|
__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)
|
__metadata("design:returntype", Promise)
|
||||||
], TourController.prototype, "getPublicTours", null);
|
], TourController.prototype, "getPublicTours", null);
|
||||||
__decorate([
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||||
Get(':id'),
|
Get(':id'),
|
||||||
__param(0, Param('id', ParseIntPipe)),
|
__param(0, Param('id', ParseUUIDPipe)),
|
||||||
__metadata("design:type", Function),
|
__metadata("design:type", Function),
|
||||||
__metadata("design:paramtypes", [Number]),
|
__metadata("design:paramtypes", [String]),
|
||||||
__metadata("design:returntype", Promise)
|
__metadata("design:returntype", Promise)
|
||||||
], TourController.prototype, "getTourDetails", null);
|
], TourController.prototype, "getTourDetails", null);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||||
|
Post(':tourId/members'),
|
||||||
|
__param(0, Param('tourId', ParseUUIDPipe)),
|
||||||
|
__param(1, Body()),
|
||||||
|
__param(2, Req()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, Object, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "addMember", null);
|
||||||
TourController = __decorate([
|
TourController = __decorate([
|
||||||
Controller('v1/tours'),
|
Controller('v1/tours'),
|
||||||
__metadata("design:paramtypes", [PrismaService])
|
__metadata("design:paramtypes", [PrismaService])
|
||||||
], TourController);
|
], TourController);
|
||||||
|
let LocationController = class LocationController {
|
||||||
|
constructor(prisma) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
}
|
||||||
|
async updateLocation(id, body) {
|
||||||
|
const { expenseAmount, expenseCategory, ...data } = body;
|
||||||
|
const location = await this.prisma.location.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
address: data.address,
|
||||||
|
latitude: data.latitude,
|
||||||
|
longitude: data.longitude,
|
||||||
|
type: data.type,
|
||||||
|
plannedStart: data.plannedStart ? new Date(data.plannedStart) : undefined,
|
||||||
|
plannedEnd: data.plannedEnd ? new Date(data.plannedEnd) : undefined,
|
||||||
|
status: data.status,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (expenseAmount !== undefined) {
|
||||||
|
const amount = Number(expenseAmount);
|
||||||
|
const existingExpense = await this.prisma.expense.findFirst({
|
||||||
|
where: { locationId: id }
|
||||||
|
});
|
||||||
|
if (existingExpense) {
|
||||||
|
await this.prisma.expense.update({
|
||||||
|
where: { id: existingExpense.id },
|
||||||
|
data: { amount, category: expenseCategory || 'OTHER' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (amount > 0) {
|
||||||
|
await this.prisma.expense.create({
|
||||||
|
data: {
|
||||||
|
amount,
|
||||||
|
category: expenseCategory || 'OTHER',
|
||||||
|
locationId: id,
|
||||||
|
legId: location.legId,
|
||||||
|
description: `Chi phí tại ${location.name}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
async deleteLocation(id) {
|
||||||
|
await this.prisma.location.delete({ where: { id } });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
__decorate([
|
||||||
|
Patch(':id'),
|
||||||
|
__param(0, Param('id', ParseUUIDPipe)),
|
||||||
|
__param(1, Body()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], LocationController.prototype, "updateLocation", null);
|
||||||
|
__decorate([
|
||||||
|
Delete(':id'),
|
||||||
|
__param(0, Param('id', ParseUUIDPipe)),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], LocationController.prototype, "deleteLocation", null);
|
||||||
|
LocationController = __decorate([
|
||||||
|
Controller('v1/locations'),
|
||||||
|
UseGuards(JwtAuthGuard),
|
||||||
|
__metadata("design:paramtypes", [PrismaService])
|
||||||
|
], LocationController);
|
||||||
|
let LegController = class LegController {
|
||||||
|
constructor(prisma) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
}
|
||||||
|
async updateLeg(id, body) {
|
||||||
|
return this.prisma.leg.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
note: body.note,
|
||||||
|
sequence: body.sequence
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async deleteLeg(id) {
|
||||||
|
const leg = await this.prisma.leg.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { _count: { select: { locations: true } } }
|
||||||
|
});
|
||||||
|
if (leg?._count.locations && leg._count.locations > 0) {
|
||||||
|
throw new BadRequestException('Không thể xóa chặng đang có địa điểm. Hãy xóa địa điểm trước.');
|
||||||
|
}
|
||||||
|
await this.prisma.leg.delete({ where: { id } });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
__decorate([
|
||||||
|
Patch(':id'),
|
||||||
|
__param(0, Param('id', ParseUUIDPipe)),
|
||||||
|
__param(1, Body()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], LegController.prototype, "updateLeg", null);
|
||||||
|
__decorate([
|
||||||
|
Delete(':id'),
|
||||||
|
__param(0, Param('id', ParseUUIDPipe)),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], LegController.prototype, "deleteLeg", null);
|
||||||
|
LegController = __decorate([
|
||||||
|
Controller('v1/legs'),
|
||||||
|
UseGuards(JwtAuthGuard),
|
||||||
|
__metadata("design:paramtypes", [PrismaService])
|
||||||
|
], LegController);
|
||||||
|
function calculateDistance(lat1, lon1, lat2, lon2) {
|
||||||
|
const p = 0.017453292519943295;
|
||||||
|
const c = Math.cos;
|
||||||
|
const a = 0.5 - c((lat2 - lat1) * p) / 2 +
|
||||||
|
c(lat1 * p) * c(lat2 * p) *
|
||||||
|
(1 - c((lon2 - lon1) * p)) / 2;
|
||||||
|
return 12742 * Math.asin(Math.sqrt(a));
|
||||||
|
}
|
||||||
|
let RoutingController = class RoutingController {
|
||||||
|
constructor(prisma) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
}
|
||||||
|
async optimize(legId) {
|
||||||
|
const currentLeg = await this.prisma.leg.findUnique({
|
||||||
|
where: { id: legId },
|
||||||
|
});
|
||||||
|
if (!currentLeg)
|
||||||
|
throw new NotFoundException('Không tìm thấy chặng');
|
||||||
|
const locations = await this.prisma.location.findMany({
|
||||||
|
where: { legId },
|
||||||
|
});
|
||||||
|
if (locations.length === 0)
|
||||||
|
return { locations: [], totalDistance: 0 };
|
||||||
|
let startAnchor = null;
|
||||||
|
const prevLeg = await this.prisma.leg.findFirst({
|
||||||
|
where: {
|
||||||
|
tourId: currentLeg.tourId,
|
||||||
|
sequence: currentLeg.sequence - 1
|
||||||
|
},
|
||||||
|
include: { locations: { orderBy: { plannedStart: 'asc' } } }
|
||||||
|
});
|
||||||
|
if (prevLeg?.locations?.length) {
|
||||||
|
startAnchor = prevLeg.locations[prevLeg.locations.length - 1];
|
||||||
|
}
|
||||||
|
if (locations.length <= 2 && !startAnchor)
|
||||||
|
return { locations, totalDistance: 0 };
|
||||||
|
const optimized = [];
|
||||||
|
const unvisited = [...locations];
|
||||||
|
let current;
|
||||||
|
if (startAnchor) {
|
||||||
|
let nearestIdx = 0;
|
||||||
|
let minDist = Infinity;
|
||||||
|
for (let i = 0; i < unvisited.length; i++) {
|
||||||
|
const d = calculateDistance(startAnchor.latitude, startAnchor.longitude, unvisited[i].latitude, unvisited[i].longitude);
|
||||||
|
if (d < minDist) {
|
||||||
|
minDist = d;
|
||||||
|
nearestIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = unvisited.splice(nearestIdx, 1)[0];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
current = unvisited.sort((a, b) => (a.plannedStart?.getTime() || 0) - (b.plannedStart?.getTime() || 0)).shift();
|
||||||
|
}
|
||||||
|
optimized.push(current);
|
||||||
|
while (unvisited.length > 0) {
|
||||||
|
let nearestIdx = 0;
|
||||||
|
let minDist = Infinity;
|
||||||
|
for (let i = 0; i < unvisited.length; i++) {
|
||||||
|
const d = calculateDistance(current.latitude, current.longitude, unvisited[i].latitude, unvisited[i].longitude);
|
||||||
|
if (d < minDist) {
|
||||||
|
minDist = d;
|
||||||
|
nearestIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = unvisited.splice(nearestIdx, 1)[0];
|
||||||
|
optimized.push(current);
|
||||||
|
}
|
||||||
|
let totalDistance = 0;
|
||||||
|
if (startAnchor) {
|
||||||
|
totalDistance += calculateDistance(startAnchor.latitude, startAnchor.longitude, optimized[0].latitude, optimized[0].longitude);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < optimized.length - 1; i++) {
|
||||||
|
totalDistance += calculateDistance(optimized[i].latitude, optimized[i].longitude, optimized[i + 1].latitude, optimized[i + 1].longitude);
|
||||||
|
}
|
||||||
|
const baseTime = optimized[0].plannedStart || new Date();
|
||||||
|
await Promise.all(optimized.map((loc, index) => this.prisma.location.update({
|
||||||
|
where: { id: loc.id },
|
||||||
|
data: { plannedStart: new Date(baseTime.getTime() + index * 3600000) },
|
||||||
|
})));
|
||||||
|
const updatedLocations = await this.prisma.location.findMany({
|
||||||
|
where: { legId },
|
||||||
|
orderBy: { plannedStart: 'asc' }
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
locations: updatedLocations,
|
||||||
|
totalDistance: parseFloat(totalDistance.toFixed(2))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
__decorate([
|
||||||
|
Post('optimize/:legId'),
|
||||||
|
__param(0, Param('legId', ParseUUIDPipe)),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], RoutingController.prototype, "optimize", null);
|
||||||
|
RoutingController = __decorate([
|
||||||
|
Controller('v1/routing'),
|
||||||
|
__metadata("design:paramtypes", [PrismaService])
|
||||||
|
], RoutingController);
|
||||||
let UserController = class UserController {
|
let UserController = class UserController {
|
||||||
constructor(prisma) {
|
constructor(prisma) {
|
||||||
this.prisma = prisma;
|
this.prisma = prisma;
|
||||||
}
|
}
|
||||||
async getAllUsers() {
|
async getAllUsers(req, q) {
|
||||||
return this.prisma.user.findMany({
|
const currentUserId = req.user?.sub;
|
||||||
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true, createdAt: true }
|
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) {
|
async updateUser(id, data) {
|
||||||
if (data.password) {
|
if (data.password) {
|
||||||
@@ -178,55 +698,61 @@ let UserController = class UserController {
|
|||||||
}
|
}
|
||||||
async deleteUser(id) {
|
async deleteUser(id) {
|
||||||
const user = await this.prisma.user.findUnique({ where: { 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 } });
|
const adminCount = await this.prisma.user.count({ where: { isAdmin: true } });
|
||||||
if (adminCount <= 1)
|
if (adminCount <= 1)
|
||||||
throw new BadRequestException('Không thể xóa Quản trị viên cuối cùng');
|
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 } });
|
await this.prisma.user.delete({ where: { id } });
|
||||||
return { success: true };
|
return { message: 'Đã xóa người dùng' };
|
||||||
}
|
}
|
||||||
async toggleBlock(id) {
|
async toggleBlock(id) {
|
||||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||||
if (!user)
|
if (!user)
|
||||||
throw new NotFoundException('Người dùng không tồn tại');
|
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 },
|
where: { id },
|
||||||
data: { isBlocked: !user.isBlocked }
|
data: { isBlocked: !user.isBlocked },
|
||||||
|
select: { id: true, email: true, name: true, isBlocked: true }
|
||||||
});
|
});
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
__decorate([
|
__decorate([
|
||||||
Get(),
|
Get(),
|
||||||
|
__param(0, Req()),
|
||||||
|
__param(1, Query('q')),
|
||||||
__metadata("design:type", Function),
|
__metadata("design:type", Function),
|
||||||
__metadata("design:paramtypes", []),
|
__metadata("design:paramtypes", [Object, String]),
|
||||||
__metadata("design:returntype", Promise)
|
__metadata("design:returntype", Promise)
|
||||||
], UserController.prototype, "getAllUsers", null);
|
], UserController.prototype, "getAllUsers", null);
|
||||||
__decorate([
|
__decorate([
|
||||||
Patch(':id'),
|
Patch(':id'),
|
||||||
__param(0, Param('id', ParseIntPipe)),
|
__param(0, Param('id', ParseUUIDPipe)),
|
||||||
__param(1, Body()),
|
__param(1, Body()),
|
||||||
__metadata("design:type", Function),
|
__metadata("design:type", Function),
|
||||||
__metadata("design:paramtypes", [Number, Object]),
|
__metadata("design:paramtypes", [String, Object]),
|
||||||
__metadata("design:returntype", Promise)
|
__metadata("design:returntype", Promise)
|
||||||
], UserController.prototype, "updateUser", null);
|
], UserController.prototype, "updateUser", null);
|
||||||
__decorate([
|
__decorate([
|
||||||
Delete(':id'),
|
Delete(':id'),
|
||||||
__param(0, Param('id', ParseIntPipe)),
|
__param(0, Param('id', ParseUUIDPipe)),
|
||||||
__metadata("design:type", Function),
|
__metadata("design:type", Function),
|
||||||
__metadata("design:paramtypes", [Number]),
|
__metadata("design:paramtypes", [String]),
|
||||||
__metadata("design:returntype", Promise)
|
__metadata("design:returntype", Promise)
|
||||||
], UserController.prototype, "deleteUser", null);
|
], UserController.prototype, "deleteUser", null);
|
||||||
__decorate([
|
__decorate([
|
||||||
Post('block/:id'),
|
Post('block/:id'),
|
||||||
__param(0, Param('id', ParseIntPipe)),
|
__param(0, Param('id', ParseUUIDPipe)),
|
||||||
__metadata("design:type", Function),
|
__metadata("design:type", Function),
|
||||||
__metadata("design:paramtypes", [Number]),
|
__metadata("design:paramtypes", [String]),
|
||||||
__metadata("design:returntype", Promise)
|
__metadata("design:returntype", Promise)
|
||||||
], UserController.prototype, "toggleBlock", null);
|
], UserController.prototype, "toggleBlock", null);
|
||||||
UserController = __decorate([
|
UserController = __decorate([
|
||||||
Controller('v1/users'),
|
Controller('v1/users'),
|
||||||
UseGuards(JwtAuthGuard, AdminGuard),
|
|
||||||
__metadata("design:paramtypes", [PrismaService])
|
__metadata("design:paramtypes", [PrismaService])
|
||||||
], UserController);
|
], UserController);
|
||||||
let AppModule = class AppModule {
|
let AppModule = class AppModule {
|
||||||
@@ -239,8 +765,8 @@ AppModule = __decorate([
|
|||||||
signOptions: { expiresIn: '1d' },
|
signOptions: { expiresIn: '1d' },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AppController, AuthController, TourController, UserController],
|
controllers: [AppController, AuthController, TourController, UserController, RoutingController, LegController, LocationController],
|
||||||
providers: [PrismaService, JwtStrategy],
|
providers: [PrismaService, JwtStrategy, TourRoleGuard],
|
||||||
exports: [PrismaService]
|
exports: [PrismaService]
|
||||||
})
|
})
|
||||||
], AppModule);
|
], 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: {
|
datasource: {
|
||||||
url: process.env.DATABASE_URL,
|
url: process.env.DATABASE_URL,
|
||||||
},
|
},
|
||||||
|
schema: 'prisma/schema.prisma',
|
||||||
});
|
});
|
||||||
//# sourceMappingURL=prisma.config.js.map
|
//# sourceMappingURL=prisma.config.js.map
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"prisma.config.js","sourceRoot":"","sources":["../prisma.config.ts"],"names":[],"mappings":"AAAA,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 { OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import 'dotenv/config';
|
|
||||||
export declare class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
export declare class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
private pool;
|
|
||||||
constructor();
|
constructor();
|
||||||
onModuleInit(): Promise<void>;
|
onModuleInit(): Promise<void>;
|
||||||
onModuleDestroy(): Promise<void>;
|
onModuleDestroy(): Promise<void>;
|
||||||
|
|||||||
Vendored
+1
-6
@@ -11,19 +11,14 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
import 'dotenv/config';
|
|
||||||
let PrismaService = class PrismaService extends PrismaClient {
|
let PrismaService = class PrismaService extends PrismaClient {
|
||||||
constructor() {
|
constructor() {
|
||||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
const adapter = new PrismaPg(pool);
|
const adapter = new PrismaPg(pool);
|
||||||
super({ adapter });
|
super({ adapter });
|
||||||
this.pool = pool;
|
|
||||||
}
|
}
|
||||||
async onModuleInit() { await this.$connect(); }
|
async onModuleInit() { await this.$connect(); }
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() { await this.$disconnect(); }
|
||||||
await this.$disconnect();
|
|
||||||
await this.pool.end();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
PrismaService = __decorate([
|
PrismaService = __decorate([
|
||||||
Injectable(),
|
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
+7
-7
@@ -16,12 +16,12 @@ let TourRoleGuard = class TourRoleGuard {
|
|||||||
async canActivate(context) {
|
async canActivate(context) {
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const user = request.user;
|
const user = request.user;
|
||||||
const tourId = parseInt(request.params.id || request.params.tourId);
|
const tourId = request.params.id || request.params.tourId;
|
||||||
const path = request.url;
|
const path = request.url;
|
||||||
if (!user || isNaN(tourId)) {
|
if (!user || !tourId) {
|
||||||
throw new ForbiddenException("Thông tin xác thực hoặc mã Tour không hợp lệ.");
|
throw new ForbiddenException("Thông tin xác thực hoặc mã Tour không hợp lệ.");
|
||||||
}
|
}
|
||||||
const membership = await this.prisma.tourMember.findUnique({
|
const participation = await this.prisma.tourParticipant.findUnique({
|
||||||
where: {
|
where: {
|
||||||
tourId_userId: {
|
tourId_userId: {
|
||||||
tourId: tourId,
|
tourId: tourId,
|
||||||
@@ -29,14 +29,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.");
|
throw new ForbiddenException("Bạn không phải là thành viên của tour này.");
|
||||||
}
|
}
|
||||||
request.tourMembership = membership;
|
request.tourParticipation = participation;
|
||||||
const role = membership.role;
|
const role = participation.role;
|
||||||
const isPlanPath = path.includes('/plans');
|
const isPlanPath = path.includes('/plans');
|
||||||
const isExpensePath = path.includes('/expenses');
|
const isExpensePath = path.includes('/expenses');
|
||||||
if ((role === 'MEMBER_PHOTO_ONLY' || role === 'VIEWER_EXTERNAL') &&
|
if ((role === 'MEMBER_NO_FINANCE' || role === 'VIEWER_ONLY') &&
|
||||||
(isPlanPath || isExpensePath)) {
|
(isPlanPath || isExpensePath)) {
|
||||||
throw new ForbiddenException("Bạn không có quyền xem kế hoạch và chi phí của tour này.");
|
throw new ForbiddenException("Bạn không có quyền xem kế hoạch và chi phí của tour này.");
|
||||||
}
|
}
|
||||||
|
|||||||
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;QAG1B,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;QAOD,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;QAGD,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;QAGjD,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;AAlDY,aAAa;IADzB,UAAU,EAAE;qCAEiB,aAAa;GAD9B,aAAa,CAkDzB"}
|
||||||
Vendored
+16
-15
@@ -2,6 +2,7 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
const adapter = new PrismaPg(pool);
|
const adapter = new PrismaPg(pool);
|
||||||
const prisma = new PrismaClient({ adapter });
|
const prisma = new PrismaClient({ adapter });
|
||||||
@@ -16,14 +17,15 @@ async function main() {
|
|||||||
data: {
|
data: {
|
||||||
email: 'owner@travel.com',
|
email: 'owner@travel.com',
|
||||||
name: 'Lộc Phạm (Chủ Tour)',
|
name: 'Lộc Phạm (Chủ Tour)',
|
||||||
passwordHash: 'hashed_password_123',
|
passwordHash: await bcrypt.hash('123456', 10),
|
||||||
|
isAdmin: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const photoMember = await prisma.user.create({
|
const photoMember = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: 'photomember@travel.com',
|
email: 'photomember@travel.com',
|
||||||
name: 'Nguyễn Văn Ảnh (Chỉ xem ảnh)',
|
name: 'Nguyễn Văn Ảnh (Chỉ xem ảnh)',
|
||||||
passwordHash: 'hashed_password_456',
|
passwordHash: await bcrypt.hash('123456', 10),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log('--- Đang tạo Tour và phân quyền... ---');
|
console.log('--- Đang tạo Tour và phân quyền... ---');
|
||||||
@@ -32,11 +34,11 @@ async function main() {
|
|||||||
title: 'Hành trình khám phá TP.HCM',
|
title: 'Hành trình khám phá TP.HCM',
|
||||||
startDate: new Date('2023-11-20'),
|
startDate: new Date('2023-11-20'),
|
||||||
endDate: new Date('2023-11-21'),
|
endDate: new Date('2023-11-21'),
|
||||||
creatorId: owner.id,
|
createdById: owner.id,
|
||||||
members: {
|
participants: {
|
||||||
create: [
|
create: [
|
||||||
{ userId: owner.id, role: 'OWNER' },
|
{ userId: owner.id, role: 'OWNER' },
|
||||||
{ userId: photoMember.id, role: 'MEMBER_PHOTO_ONLY' },
|
{ userId: photoMember.id, role: 'VIEWER_ONLY' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -45,38 +47,37 @@ async function main() {
|
|||||||
const leg1 = await prisma.leg.create({
|
const leg1 = await prisma.leg.create({
|
||||||
data: {
|
data: {
|
||||||
tourId: tour.id,
|
tourId: tour.id,
|
||||||
sequenceNumber: 1,
|
sequence: 1,
|
||||||
notes: 'Khám phá lịch sử trung tâm',
|
note: 'Khám phá lịch sử trung tâm',
|
||||||
places: {
|
locations: {
|
||||||
create: [
|
create: [
|
||||||
{
|
{
|
||||||
name: 'Dinh Độc Lập',
|
name: 'Dinh Độc Lập',
|
||||||
address: '135 Nam Kỳ Khởi Nghĩa, Quận 1',
|
address: '135 Nam Kỳ Khởi Nghĩa, Quận 1',
|
||||||
latitude: 10.777,
|
latitude: 10.777,
|
||||||
longitude: 106.695,
|
longitude: 106.695,
|
||||||
sequenceInLeg: 1,
|
plannedStart: new Date('2023-11-20T08:00:00Z'),
|
||||||
arrivalTime: new Date('2023-11-20T08:00:00Z'),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Bưu điện Thành phố',
|
name: 'Bưu điện Thành phố',
|
||||||
address: '02 Công xã Paris, Quận 1',
|
address: '02 Công xã Paris, Quận 1',
|
||||||
latitude: 10.779,
|
latitude: 10.779,
|
||||||
longitude: 106.699,
|
longitude: 106.699,
|
||||||
sequenceInLeg: 2,
|
plannedStart: new Date('2023-11-20T10:00:00Z'),
|
||||||
arrivalTime: new Date('2023-11-20T10:00:00Z'),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log('--- Đang tạo chi phí mẫu... ---');
|
console.log('--- Đang tạo chi phí mẫu... ---');
|
||||||
await prisma.expense.create({
|
const expense1 = await prisma.expense.create({
|
||||||
data: {
|
data: {
|
||||||
legId: leg1.id,
|
legId: leg1.id,
|
||||||
category: 'DINING',
|
category: 'FOOD',
|
||||||
amount: 500000,
|
amount: 500000,
|
||||||
currency: 'VND',
|
|
||||||
description: 'Ăn trưa đặc sản Quận 1',
|
description: 'Ăn trưa đặc sản Quận 1',
|
||||||
|
note: 'Đặt trước cho 3 người',
|
||||||
|
paidById: owner.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log('--- Seed dữ liệu hoàn tất! ---');
|
console.log('--- Seed dữ liệu hoàn tất! ---');
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"seed.js","sourceRoot":"","sources":["../seed.ts"],"names":[],"mappings":"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"}
|
{"version":3,"file":"seed.js","sourceRoot":"","sources":["../seed.ts"],"names":[],"mappings":"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"}
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+22
-2
@@ -3,10 +3,30 @@ interface TourState {
|
|||||||
legs: any[];
|
legs: any[];
|
||||||
publicTours: any[];
|
publicTours: any[];
|
||||||
userRole: string | null;
|
userRole: string | null;
|
||||||
|
activeLegId: string | null;
|
||||||
|
mapCenter: [number, number];
|
||||||
setTour: (tour: any) => void;
|
setTour: (tour: any) => void;
|
||||||
updateLegs: (legs: any[]) => void;
|
updateLegs: (legs: any[]) => void;
|
||||||
optimizeRouting: () => Promise<void>;
|
createTour: (tourData: any) => Promise<any>;
|
||||||
fetchTour: (id: number) => Promise<void>;
|
updateTour: (id: string, data: any) => Promise<void>;
|
||||||
|
deleteTour: (id: string) => Promise<void>;
|
||||||
|
addLeg: (tourId: string, data: any) => Promise<void>;
|
||||||
|
initializeLegs: (tourId: string, count: number) => Promise<void>;
|
||||||
|
updateLeg: (legId: string, data: any) => Promise<void>;
|
||||||
|
deleteLeg: (legId: string) => Promise<void>;
|
||||||
|
addLocation: (tourId: string, locationData: any) => Promise<void>;
|
||||||
|
updateLocation: (locationId: string, data: any) => Promise<void>;
|
||||||
|
deleteLocation: (locationId: string) => Promise<void>;
|
||||||
|
updateTourStartPoint: (tourId: string, data: any) => Promise<void>;
|
||||||
|
updateTourEndPoint: (tourId: string, data: any) => Promise<void>;
|
||||||
|
optimizeRouting: (legId: string) => Promise<void>;
|
||||||
|
addMember: (tourId: string, member: {
|
||||||
|
userId: string;
|
||||||
|
role?: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
setActiveLegId: (id: string | null) => void;
|
||||||
|
setMapCenter: (pos: [number, number]) => void;
|
||||||
|
fetchTour: (id: string) => Promise<void>;
|
||||||
fetchPublicTours: () => Promise<void>;
|
fetchPublicTours: () => Promise<void>;
|
||||||
}
|
}
|
||||||
export declare const useTourStore: import("zustand").UseBoundStore<import("zustand").StoreApi<TourState>>;
|
export declare const useTourStore: import("zustand").UseBoundStore<import("zustand").StoreApi<TourState>>;
|
||||||
|
|||||||
Vendored
+244
-6
@@ -4,21 +4,259 @@ export const useTourStore = create((set, get) => ({
|
|||||||
legs: [],
|
legs: [],
|
||||||
publicTours: [],
|
publicTours: [],
|
||||||
userRole: null,
|
userRole: null,
|
||||||
|
activeLegId: null,
|
||||||
|
mapCenter: [10.7769, 106.7009],
|
||||||
setTour: (tour) => set({ currentTour: tour }),
|
setTour: (tour) => set({ currentTour: tour }),
|
||||||
updateLegs: (legs) => set({ legs }),
|
updateLegs: (legs) => set({ legs }),
|
||||||
|
setActiveLegId: (id) => set({ activeLegId: id }),
|
||||||
|
setMapCenter: (pos) => set({ mapCenter: pos }),
|
||||||
fetchTour: async (id) => {
|
fetchTour: async (id) => {
|
||||||
const currentUserId = 1;
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
const response = await fetch(`http://localhost:3001/api/v1/tours/${id}`);
|
const token = localStorage.getItem('token');
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const role = data.members?.find((m) => m.userId === currentUserId)?.role || 'VIEWER_EXTERNAL';
|
let role = 'VIEWER_ONLY';
|
||||||
set({ currentTour: data, legs: data.legs || [], userRole: role });
|
if (token) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
const currentUserId = payload.sub;
|
||||||
|
const participant = data.participants?.find((p) => p.userId === currentUserId);
|
||||||
|
if (participant)
|
||||||
|
role = participant.role;
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error("Lỗi khi xác định vai trò người dùng:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const legs = data.legs || [];
|
||||||
|
set({ currentTour: data, legs, userRole: role, activeLegId: legs.length > 0 ? legs[0].id : null });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('Không thể tải tour:', err);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
fetchPublicTours: async () => {
|
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();
|
const data = await response.json();
|
||||||
set({ publicTours: data });
|
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),
|
||||||
|
});
|
||||||
|
return await response.json();
|
||||||
|
},
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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)
|
||||||
|
throw new Error('Lỗi khi thêm thành viên');
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour)
|
||||||
|
get().fetchTour(currentTour.id);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
//# sourceMappingURL=useTourStore.js.map
|
//# sourceMappingURL=useTourStore.js.map
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
|||||||
|
# docs/help/
|
||||||
|
Help snippet index for this project.
|
||||||
@@ -7,3 +7,21 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.banner-location-text {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 80px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: bottom;
|
||||||
|
cursor: help; /* Hiển thị biểu tượng giúp đỡ để gợi ý có tooltip */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.banner-location-text {
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
import { NestFactory } from '@nestjs/core';
|
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 } from '@nestjs/common';
|
||||||
import { PrismaService } from './prisma.service.js';
|
import { PrismaService } from './prisma.service.js';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
@@ -7,6 +8,7 @@ import { AdminGuard } from './admin.guard.js';
|
|||||||
import { JwtModule, JwtService } from '@nestjs/jwt';
|
import { JwtModule, JwtService } from '@nestjs/jwt';
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard.js';
|
import { JwtAuthGuard } from './jwt-auth.guard.js';
|
||||||
import { JwtStrategy } from './jwt.strategy.js';
|
import { JwtStrategy } from './jwt.strategy.js';
|
||||||
|
import { TourRoleGuard } from './rbac.middleware.js';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
class AppController {
|
class AppController {
|
||||||
@@ -56,22 +58,19 @@ class AuthController {
|
|||||||
|
|
||||||
@Post('signup')
|
@Post('signup')
|
||||||
async signup(@Body() body: any) {
|
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 } });
|
const existingUser = await this.prisma.user.findUnique({ where: { email } });
|
||||||
if (existingUser) throw new BadRequestException('Email đã được sử dụng');
|
if (existingUser) throw new BadRequestException('Email đã được sử dụng');
|
||||||
|
|
||||||
// 2. Logic "First User is Admin"
|
|
||||||
const userCount = await this.prisma.user.count();
|
const userCount = await this.prisma.user.count();
|
||||||
const shouldBeAdmin = userCount === 0;
|
const shouldBeAdmin = userCount === 0;
|
||||||
|
|
||||||
// 3. Hash mật khẩu
|
|
||||||
const passwordHash = await bcrypt.hash(password, 10);
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
return this.prisma.user.create({
|
return this.prisma.user.create({
|
||||||
data: { email, passwordHash, name, isAdmin: shouldBeAdmin },
|
data: { email, passwordHash, name, phone, address, isAdmin: shouldBeAdmin },
|
||||||
select: { id: true, email: true, name: true, isAdmin: true }
|
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,35 +79,274 @@ class AuthController {
|
|||||||
class TourController {
|
class TourController {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
@Get('explore')
|
@UseGuards(JwtAuthGuard)
|
||||||
async getPublicTours() {
|
@Post()
|
||||||
// Lấy các tour có ít nhất 1 ảnh và thông tin vị trí từ chặng đầu tiên
|
async createTour(@Body() body: any, @Req() req: any) {
|
||||||
return this.prisma.tour.findMany({
|
const { title, startDate, endDate } = body;
|
||||||
take: 20,
|
return this.prisma.tour.create({
|
||||||
include: {
|
data: {
|
||||||
photos: { take: 1 },
|
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: {
|
legs: {
|
||||||
take: 1,
|
create: {
|
||||||
include: {
|
sequence: 1,
|
||||||
places: { take: 1 }
|
note: 'Chặng khởi đầu'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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(@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: {
|
||||||
|
orderBy: { sequence: 'asc' },
|
||||||
|
include: {
|
||||||
|
locations: { orderBy: { plannedStart: 'asc' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async getTourDetails(@Param('id', ParseIntPipe) id: number) {
|
async getTourDetails(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
const tour = await this.prisma.tour.findUnique({
|
const tour = await this.prisma.tour.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
members: true,
|
participants: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: { id: true, name: true, email: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
photos: true,
|
photos: true,
|
||||||
legs: {
|
legs: {
|
||||||
orderBy: { sequenceNumber: 'asc' },
|
orderBy: { sequence: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
places: { orderBy: { sequenceInLeg: 'asc' } },
|
locations: { orderBy: { plannedStart: 'asc' } },
|
||||||
expenses: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -117,23 +355,271 @@ class TourController {
|
|||||||
if (!tour) throw new NotFoundException(`Không tìm thấy Tour với ID ${id}`);
|
if (!tour) throw new NotFoundException(`Không tìm thấy Tour với ID ${id}`);
|
||||||
return tour;
|
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 } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.prisma.tourParticipant.create({
|
||||||
|
data: {
|
||||||
|
tourId,
|
||||||
|
userId: body.userId,
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
include: { user: { select: { id: true, name: true, email: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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')
|
@Controller('v1/users')
|
||||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
|
||||||
class UserController {
|
class UserController {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async getAllUsers() {
|
async getAllUsers(@Req() req: any, @Query('q') q?: string) {
|
||||||
return this.prisma.user.findMany({
|
const currentUserId = req.user?.sub;
|
||||||
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true, createdAt: true }
|
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')
|
@Patch(':id')
|
||||||
async updateUser(@Param('id', ParseIntPipe) id: number, @Body() data: any) {
|
async updateUser(@Param('id', ParseUUIDPipe) id: string, @Body() data: any) {
|
||||||
// Nếu đổi mật khẩu thì cần hash
|
|
||||||
if (data.password) {
|
if (data.password) {
|
||||||
data.passwordHash = await bcrypt.hash(data.password, 10);
|
data.passwordHash = await bcrypt.hash(data.password, 10);
|
||||||
delete data.password;
|
delete data.password;
|
||||||
@@ -146,25 +632,28 @@ class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
async deleteUser(@Param('id', ParseIntPipe) id: number) {
|
async deleteUser(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
// 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)
|
|
||||||
const user = await this.prisma.user.findUnique({ where: { 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 } });
|
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');
|
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 } });
|
await this.prisma.user.delete({ where: { id } });
|
||||||
return { success: true };
|
return { message: 'Đã xóa người dùng' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('block/:id')
|
@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 } });
|
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||||
if (!user) throw new NotFoundException('Người dùng không tồn tại');
|
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 },
|
where: { id },
|
||||||
data: { isBlocked: !user.isBlocked }
|
data: { isBlocked: !user.isBlocked },
|
||||||
|
select: { id: true, email: true, name: true, isBlocked: true }
|
||||||
});
|
});
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,8 +664,8 @@ class UserController {
|
|||||||
signOptions: { expiresIn: '1d' },
|
signOptions: { expiresIn: '1d' },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AppController, AuthController, TourController, UserController],
|
controllers: [AppController, AuthController, TourController, UserController, RoutingController, LegController, LocationController],
|
||||||
providers: [PrismaService, JwtStrategy],
|
providers: [PrismaService, JwtStrategy, TourRoleGuard],
|
||||||
exports: [PrismaService]
|
exports: [PrismaService]
|
||||||
})
|
})
|
||||||
class AppModule {}
|
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;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# Please do not edit this file manually
|
|
||||||
# It should be added in your version-control system (e.g., Git)
|
|
||||||
provider = "postgresql"
|
|
||||||
Generated
+118
-93
@@ -26,6 +26,7 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
|
"react-leaflet-cluster": "^2.1.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"zustand": "^5.0.1"
|
"zustand": "^5.0.1"
|
||||||
@@ -281,14 +282,14 @@
|
|||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz",
|
||||||
"integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==",
|
"integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@electric-sql/pglite-socket": {
|
"node_modules/@electric-sql/pglite-socket": {
|
||||||
"version": "0.1.1",
|
"version": "0.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz",
|
||||||
"integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==",
|
"integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"pglite-server": "dist/scripts/server.js"
|
"pglite-server": "dist/scripts/server.js"
|
||||||
@@ -301,7 +302,7 @@
|
|||||||
"version": "0.3.1",
|
"version": "0.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz",
|
||||||
"integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==",
|
"integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@electric-sql/pglite": "0.4.1"
|
"@electric-sql/pglite": "0.4.1"
|
||||||
@@ -787,7 +788,7 @@
|
|||||||
"version": "1.19.11",
|
"version": "1.19.11",
|
||||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
|
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
|
||||||
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
|
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.14.1"
|
"node": ">=18.14.1"
|
||||||
@@ -964,7 +965,7 @@
|
|||||||
"version": "0.3.4",
|
"version": "0.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@ljharb/through": {
|
"node_modules/@ljharb/through": {
|
||||||
@@ -1293,7 +1294,7 @@
|
|||||||
"version": "7.8.0",
|
"version": "7.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz",
|
||||||
"integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==",
|
"integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"c12": "3.3.4",
|
"c12": "3.3.4",
|
||||||
@@ -1312,7 +1313,7 @@
|
|||||||
"version": "0.24.3",
|
"version": "0.24.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz",
|
||||||
"integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==",
|
"integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electric-sql/pglite": "0.4.1",
|
"@electric-sql/pglite": "0.4.1",
|
||||||
@@ -1347,7 +1348,7 @@
|
|||||||
"version": "7.8.0",
|
"version": "7.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz",
|
||||||
"integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==",
|
"integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1361,14 +1362,14 @@
|
|||||||
"version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a",
|
"version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz",
|
||||||
"integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==",
|
"integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/engines/node_modules/@prisma/get-platform": {
|
"node_modules/@prisma/engines/node_modules/@prisma/get-platform": {
|
||||||
"version": "7.8.0",
|
"version": "7.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz",
|
||||||
"integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==",
|
"integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "7.8.0"
|
"@prisma/debug": "7.8.0"
|
||||||
@@ -1378,7 +1379,7 @@
|
|||||||
"version": "7.8.0",
|
"version": "7.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz",
|
||||||
"integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==",
|
"integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "7.8.0",
|
"@prisma/debug": "7.8.0",
|
||||||
@@ -1390,7 +1391,7 @@
|
|||||||
"version": "7.8.0",
|
"version": "7.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz",
|
||||||
"integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==",
|
"integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "7.8.0"
|
"@prisma/debug": "7.8.0"
|
||||||
@@ -1400,7 +1401,7 @@
|
|||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz",
|
||||||
"integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==",
|
"integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "7.2.0"
|
"@prisma/debug": "7.2.0"
|
||||||
@@ -1410,21 +1411,21 @@
|
|||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz",
|
||||||
"integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==",
|
"integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/query-plan-executor": {
|
"node_modules/@prisma/query-plan-executor": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz",
|
||||||
"integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==",
|
"integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/streams-local": {
|
"node_modules/@prisma/streams-local": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz",
|
||||||
"integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==",
|
"integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^8.12.0",
|
"ajv": "^8.12.0",
|
||||||
@@ -1441,7 +1442,7 @@
|
|||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz",
|
||||||
"integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==",
|
"integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-toggle": "1.1.10",
|
"@radix-ui/react-toggle": "1.1.10",
|
||||||
@@ -1461,7 +1462,7 @@
|
|||||||
"version": "1.1.10",
|
"version": "1.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
|
||||||
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
|
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/primitive": "1.1.3",
|
"@radix-ui/primitive": "1.1.3",
|
||||||
@@ -1487,7 +1488,7 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-slot": "1.2.3"
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
@@ -1511,14 +1512,14 @@
|
|||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-compose-refs": {
|
"node_modules/@radix-ui/react-compose-refs": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||||
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@@ -1534,7 +1535,7 @@
|
|||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
@@ -1553,7 +1554,7 @@
|
|||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
"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==",
|
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-use-effect-event": "0.0.2",
|
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||||
@@ -1573,7 +1574,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
|
"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==",
|
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
@@ -1592,7 +1593,7 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||||
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@@ -1883,7 +1884,7 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
@@ -2403,7 +2404,7 @@
|
|||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
@@ -2424,7 +2425,7 @@
|
|||||||
"version": "18.3.31",
|
"version": "18.3.31",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
@@ -2726,7 +2727,7 @@
|
|||||||
"version": "8.12.0",
|
"version": "8.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
|
||||||
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
|
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
@@ -2929,7 +2930,7 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||||
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
|
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6.0.0"
|
"node": ">= 6.0.0"
|
||||||
@@ -2993,7 +2994,7 @@
|
|||||||
"version": "2.9.2",
|
"version": "2.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz",
|
||||||
"integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==",
|
"integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
@@ -3179,7 +3180,7 @@
|
|||||||
"version": "3.3.4",
|
"version": "3.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
|
||||||
"integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==",
|
"integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
@@ -3208,7 +3209,7 @@
|
|||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"readdirp": "^5.0.0"
|
"readdirp": "^5.0.0"
|
||||||
@@ -3224,7 +3225,7 @@
|
|||||||
"version": "17.4.2",
|
"version": "17.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -3237,7 +3238,7 @@
|
|||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 20.19.0"
|
"node": ">= 20.19.0"
|
||||||
@@ -3354,7 +3355,7 @@
|
|||||||
"version": "4.5.1",
|
"version": "4.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kurkle/color": "^0.3.0"
|
"@kurkle/color": "^0.3.0"
|
||||||
@@ -3550,7 +3551,7 @@
|
|||||||
"version": "0.2.4",
|
"version": "0.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||||
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/console-control-strings": {
|
"node_modules/console-control-strings": {
|
||||||
@@ -3654,7 +3655,7 @@
|
|||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"path-key": "^3.1.0",
|
"path-key": "^3.1.0",
|
||||||
@@ -3669,7 +3670,7 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/date-fns": {
|
"node_modules/date-fns": {
|
||||||
@@ -3719,7 +3720,7 @@
|
|||||||
"version": "7.1.5",
|
"version": "7.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||||
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
@@ -3760,7 +3761,7 @@
|
|||||||
"version": "6.1.7",
|
"version": "6.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
||||||
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/delegates": {
|
"node_modules/delegates": {
|
||||||
@@ -3773,7 +3774,7 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||||
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
@@ -3792,7 +3793,7 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||||
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
@@ -3856,7 +3857,7 @@
|
|||||||
"version": "3.20.0",
|
"version": "3.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
|
||||||
"integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==",
|
"integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.0.0",
|
||||||
@@ -3880,7 +3881,7 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
|
||||||
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
|
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
@@ -3913,7 +3914,7 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
|
||||||
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
|
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
@@ -4189,7 +4190,7 @@
|
|||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||||
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/external-editor": {
|
"node_modules/external-editor": {
|
||||||
@@ -4211,7 +4212,7 @@
|
|||||||
"version": "3.23.2",
|
"version": "3.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
||||||
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
|
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -4234,7 +4235,7 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-json-stable-stringify": {
|
"node_modules/fast-json-stable-stringify": {
|
||||||
@@ -4340,7 +4341,7 @@
|
|||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "^7.0.6",
|
"cross-spawn": "^7.0.6",
|
||||||
@@ -4521,7 +4522,7 @@
|
|||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||||
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-property": "^1.0.2"
|
"is-property": "^1.0.2"
|
||||||
@@ -4555,7 +4556,7 @@
|
|||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz",
|
||||||
"integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==",
|
"integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/get-proto": {
|
"node_modules/get-proto": {
|
||||||
@@ -4575,7 +4576,7 @@
|
|||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/giget/-/giget-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/giget/-/giget-3.3.0.tgz",
|
||||||
"integrity": "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==",
|
"integrity": "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"giget": "dist/cli.mjs"
|
"giget": "dist/cli.mjs"
|
||||||
@@ -4665,21 +4666,21 @@
|
|||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/grammex": {
|
"node_modules/grammex": {
|
||||||
"version": "3.1.12",
|
"version": "3.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz",
|
||||||
"integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==",
|
"integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/graphmatch": {
|
"node_modules/graphmatch": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz",
|
||||||
"integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==",
|
"integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
@@ -4749,7 +4750,7 @@
|
|||||||
"version": "4.12.25",
|
"version": "4.12.25",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz",
|
||||||
"integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==",
|
"integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
@@ -4779,7 +4780,7 @@
|
|||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz",
|
||||||
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==",
|
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/https-proxy-agent": {
|
"node_modules/https-proxy-agent": {
|
||||||
@@ -4980,7 +4981,7 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/is-unicode-supported": {
|
"node_modules/is-unicode-supported": {
|
||||||
@@ -5000,7 +5001,7 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/iterare": {
|
"node_modules/iterare": {
|
||||||
@@ -5063,7 +5064,7 @@
|
|||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
@@ -5109,7 +5110,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/json5": {
|
"node_modules/json5": {
|
||||||
@@ -5194,6 +5195,15 @@
|
|||||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
"license": "BSD-2-Clause"
|
"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": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -5565,7 +5575,7 @@
|
|||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
@@ -5591,7 +5601,7 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
||||||
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
|
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"bun": ">=1.0.0",
|
"bun": ">=1.0.0",
|
||||||
@@ -5857,7 +5867,7 @@
|
|||||||
"version": "3.15.3",
|
"version": "3.15.3",
|
||||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
|
||||||
"integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
|
"integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"aws-ssl-profiles": "^1.1.1",
|
"aws-ssl-profiles": "^1.1.1",
|
||||||
@@ -5878,7 +5888,7 @@
|
|||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
@@ -5895,7 +5905,7 @@
|
|||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
|
||||||
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
|
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lru.min": "^1.1.0"
|
"lru.min": "^1.1.0"
|
||||||
@@ -6055,7 +6065,7 @@
|
|||||||
"version": "2.0.11",
|
"version": "2.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
||||||
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
@@ -6226,7 +6236,7 @@
|
|||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -6273,7 +6283,7 @@
|
|||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pause": {
|
"node_modules/pause": {
|
||||||
@@ -6285,7 +6295,7 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
|
||||||
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
|
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg": {
|
"node_modules/pg": {
|
||||||
@@ -6410,7 +6420,7 @@
|
|||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
|
||||||
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
|
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"confbox": "^0.2.4",
|
"confbox": "^0.2.4",
|
||||||
@@ -6468,7 +6478,7 @@
|
|||||||
"version": "3.4.7",
|
"version": "3.4.7",
|
||||||
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz",
|
||||||
"integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==",
|
"integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -6521,7 +6531,7 @@
|
|||||||
"version": "7.8.0",
|
"version": "7.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz",
|
||||||
"integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==",
|
"integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -6555,7 +6565,7 @@
|
|||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
||||||
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
|
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.2.4",
|
"graceful-fs": "^4.2.4",
|
||||||
@@ -6567,7 +6577,7 @@
|
|||||||
"version": "3.0.7",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
@@ -6587,7 +6597,7 @@
|
|||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -6597,7 +6607,7 @@
|
|||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||||
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
|
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -6669,7 +6679,7 @@
|
|||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz",
|
||||||
"integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==",
|
"integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"defu": "^6.1.6",
|
"defu": "^6.1.6",
|
||||||
@@ -6724,6 +6734,21 @@
|
|||||||
"react-dom": "^18.0.0"
|
"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": {
|
"node_modules/readable-stream": {
|
||||||
"version": "3.6.2",
|
"version": "3.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
@@ -6774,7 +6799,7 @@
|
|||||||
"version": "2.33.4",
|
"version": "2.33.4",
|
||||||
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
|
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
|
||||||
"integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==",
|
"integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/remeda"
|
"url": "https://github.com/sponsors/remeda"
|
||||||
@@ -6794,7 +6819,7 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"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==",
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -6835,7 +6860,7 @@
|
|||||||
"version": "0.12.0",
|
"version": "0.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||||
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
|
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
@@ -7093,7 +7118,7 @@
|
|||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
|
||||||
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==",
|
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==",
|
||||||
"devOptional": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/serve-static": {
|
"node_modules/serve-static": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
@@ -7148,7 +7173,7 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"shebang-regex": "^3.0.0"
|
"shebang-regex": "^3.0.0"
|
||||||
@@ -7161,7 +7186,7 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -7243,7 +7268,7 @@
|
|||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
@@ -7306,7 +7331,7 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
|
||||||
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
|
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
@@ -7325,7 +7350,7 @@
|
|||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/streamsearch": {
|
"node_modules/streamsearch": {
|
||||||
@@ -7846,7 +7871,7 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
@@ -7940,7 +7965,7 @@
|
|||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
@@ -7965,7 +7990,7 @@
|
|||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz",
|
||||||
"integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==",
|
"integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": ">=5"
|
"typescript": ">=5"
|
||||||
@@ -8186,7 +8211,7 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"isexe": "^2.0.0"
|
"isexe": "^2.0.0"
|
||||||
@@ -8276,7 +8301,7 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz",
|
||||||
"integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==",
|
"integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"grammex": "^3.1.11",
|
"grammex": "^3.1.11",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
|
"react-leaflet-cluster": "^2.1.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"zustand": "^5.0.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
|
// Prisma CLI sẽ sử dụng url này để thực hiện migrate
|
||||||
url: process.env.DATABASE_URL,
|
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 { PrismaClient } from '@prisma/client';
|
||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
import 'dotenv/config';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
private pool: Pool;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
const adapter = new PrismaPg(pool);
|
const adapter = new PrismaPg(pool);
|
||||||
super({ adapter });
|
super({ adapter });
|
||||||
this.pool = pool;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleInit() { await this.$connect(); }
|
async onModuleInit() { await this.$connect(); }
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() { await this.$disconnect(); }
|
||||||
await this.$disconnect();
|
|
||||||
await this.pool.end();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// 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 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[]
|
||||||
|
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[]
|
||||||
|
legs Leg[]
|
||||||
|
photos Photo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
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])
|
||||||
|
}
|
||||||
+9
-9
@@ -9,11 +9,11 @@ export class TourRoleGuard implements CanActivate {
|
|||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const user = request.user; // Giả sử đã qua AuthGuard (Passport/JWT)
|
const user = request.user; // Giả sử đã qua AuthGuard (Passport/JWT)
|
||||||
|
|
||||||
// Lấy tourId từ URL params (:id hoặc :tourId)
|
// UUID không cần parseInt
|
||||||
const tourId = parseInt(request.params.id || request.params.tourId);
|
const tourId = request.params.id || request.params.tourId;
|
||||||
const path = request.url;
|
const path = request.url;
|
||||||
|
|
||||||
if (!user || isNaN(tourId)) {
|
if (!user || !tourId) {
|
||||||
throw new ForbiddenException("Thông tin xác thực hoặc mã Tour không hợp lệ.");
|
throw new ForbiddenException("Thông tin xác thực hoặc mã Tour không hợp lệ.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ export class TourRoleGuard implements CanActivate {
|
|||||||
* Chúng ta lưu kết quả vào request object để các interceptor hoặc controller
|
* 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.
|
* sau này có thể dùng lại mà không cần query lại.
|
||||||
*/
|
*/
|
||||||
const membership = await this.prisma.tourMember.findUnique({
|
const participation = await this.prisma.tourParticipant.findUnique({
|
||||||
where: {
|
where: {
|
||||||
tourId_userId: {
|
tourId_userId: {
|
||||||
tourId: tourId,
|
tourId: tourId,
|
||||||
@@ -31,20 +31,20 @@ 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.");
|
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
|
// 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 isPlanPath = path.includes('/plans');
|
||||||
const isExpensePath = path.includes('/expenses');
|
const isExpensePath = path.includes('/expenses');
|
||||||
|
|
||||||
// 1. Chặn MEMBER_PHOTO_ONLY và VIEWER_EXTERNAL truy cập Plans & Expenses
|
// Theo định nghĩa mới: MEMBER_NO_FINANCE và VIEWER_ONLY bị hạn chế
|
||||||
if (
|
if (
|
||||||
(role === 'MEMBER_PHOTO_ONLY' || role === 'VIEWER_EXTERNAL') &&
|
(role === 'MEMBER_NO_FINANCE' || role === 'VIEWER_ONLY') &&
|
||||||
(isPlanPath || isExpensePath)
|
(isPlanPath || isExpensePath)
|
||||||
) {
|
) {
|
||||||
throw new ForbiddenException("Bạn không có quyền xem kế hoạch và chi phí của tour này.");
|
throw new ForbiddenException("Bạn không có quyền xem kế hoạch và chi phí của tour này.");
|
||||||
|
|||||||
-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 { PrismaPg } from '@prisma/adapter-pg';
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
const adapter = new PrismaPg(pool);
|
const adapter = new PrismaPg(pool);
|
||||||
@@ -20,7 +21,9 @@ async function main() {
|
|||||||
data: {
|
data: {
|
||||||
email: 'owner@travel.com',
|
email: 'owner@travel.com',
|
||||||
name: 'Lộc Phạm (Chủ Tour)',
|
name: 'Lộc Phạm (Chủ Tour)',
|
||||||
passwordHash: 'hashed_password_123',
|
// 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: {
|
data: {
|
||||||
email: 'photomember@travel.com',
|
email: 'photomember@travel.com',
|
||||||
name: 'Nguyễn Văn Ảnh (Chỉ xem ảnh)',
|
name: 'Nguyễn Văn Ảnh (Chỉ xem ảnh)',
|
||||||
passwordHash: 'hashed_password_456',
|
passwordHash: await bcrypt.hash('123456', 10),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,11 +41,11 @@ async function main() {
|
|||||||
title: 'Hành trình khám phá TP.HCM',
|
title: 'Hành trình khám phá TP.HCM',
|
||||||
startDate: new Date('2023-11-20'),
|
startDate: new Date('2023-11-20'),
|
||||||
endDate: new Date('2023-11-21'),
|
endDate: new Date('2023-11-21'),
|
||||||
creatorId: owner.id,
|
createdById: owner.id,
|
||||||
members: {
|
participants: {
|
||||||
create: [
|
create: [
|
||||||
{ userId: owner.id, role: 'OWNER' },
|
{ userId: owner.id, role: 'OWNER' },
|
||||||
{ userId: photoMember.id, role: 'MEMBER_PHOTO_ONLY' },
|
{ userId: photoMember.id, role: 'VIEWER_ONLY' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -52,25 +55,23 @@ async function main() {
|
|||||||
const leg1 = await prisma.leg.create({
|
const leg1 = await prisma.leg.create({
|
||||||
data: {
|
data: {
|
||||||
tourId: tour.id,
|
tourId: tour.id,
|
||||||
sequenceNumber: 1,
|
sequence: 1,
|
||||||
notes: 'Khám phá lịch sử trung tâm',
|
note: 'Khám phá lịch sử trung tâm',
|
||||||
places: {
|
locations: {
|
||||||
create: [
|
create: [
|
||||||
{
|
{
|
||||||
name: 'Dinh Độc Lập',
|
name: 'Dinh Độc Lập',
|
||||||
address: '135 Nam Kỳ Khởi Nghĩa, Quận 1',
|
address: '135 Nam Kỳ Khởi Nghĩa, Quận 1',
|
||||||
latitude: 10.777,
|
latitude: 10.777,
|
||||||
longitude: 106.695,
|
longitude: 106.695,
|
||||||
sequenceInLeg: 1,
|
plannedStart: new Date('2023-11-20T08:00:00Z'),
|
||||||
arrivalTime: new Date('2023-11-20T08:00:00Z'),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Bưu điện Thành phố',
|
name: 'Bưu điện Thành phố',
|
||||||
address: '02 Công xã Paris, Quận 1',
|
address: '02 Công xã Paris, Quận 1',
|
||||||
latitude: 10.779,
|
latitude: 10.779,
|
||||||
longitude: 106.699,
|
longitude: 106.699,
|
||||||
sequenceInLeg: 2,
|
plannedStart: new Date('2023-11-20T10:00:00Z'),
|
||||||
arrivalTime: new Date('2023-11-20T10:00:00Z'),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -78,13 +79,14 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log('--- Đang tạo chi phí mẫu... ---');
|
console.log('--- Đang tạo chi phí mẫu... ---');
|
||||||
await prisma.expense.create({
|
const expense1 = await prisma.expense.create({
|
||||||
data: {
|
data: {
|
||||||
legId: leg1.id,
|
legId: leg1.id,
|
||||||
category: 'DINING',
|
category: 'FOOD',
|
||||||
amount: 500000,
|
amount: 500000,
|
||||||
currency: 'VND',
|
|
||||||
description: 'Ăn trưa đặc sản Quận 1',
|
description: 'Ăn trưa đặc sản Quận 1',
|
||||||
|
note: 'Đặt trước cho 3 người',
|
||||||
|
paidById: owner.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+259
-12
@@ -5,10 +5,27 @@ interface TourState {
|
|||||||
legs: any[];
|
legs: any[];
|
||||||
publicTours: any[];
|
publicTours: any[];
|
||||||
userRole: string | null;
|
userRole: string | null;
|
||||||
|
activeLegId: string | null;
|
||||||
|
mapCenter: [number, number];
|
||||||
setTour: (tour: any) => void;
|
setTour: (tour: any) => void;
|
||||||
updateLegs: (legs: any[]) => void;
|
updateLegs: (legs: any[]) => void;
|
||||||
optimizeRouting: () => Promise<void>;
|
createTour: (tourData: any) => Promise<any>;
|
||||||
fetchTour: (id: number) => Promise<void>;
|
updateTour: (id: string, data: any) => Promise<void>;
|
||||||
|
deleteTour: (id: string) => Promise<void>;
|
||||||
|
addLeg: (tourId: string, data: any) => Promise<void>;
|
||||||
|
initializeLegs: (tourId: string, count: number) => Promise<void>;
|
||||||
|
updateLeg: (legId: string, data: any) => Promise<void>;
|
||||||
|
deleteLeg: (legId: string) => Promise<void>;
|
||||||
|
addLocation: (tourId: string, locationData: any) => Promise<void>;
|
||||||
|
updateLocation: (locationId: string, data: any) => Promise<void>;
|
||||||
|
deleteLocation: (locationId: string) => Promise<void>;
|
||||||
|
updateTourStartPoint: (tourId: string, data: any) => Promise<void>;
|
||||||
|
updateTourEndPoint: (tourId: string, data: any) => Promise<void>;
|
||||||
|
optimizeRouting: (legId: string) => Promise<void>;
|
||||||
|
addMember: (tourId: string, member: { userId: string; role?: string }) => Promise<void>;
|
||||||
|
setActiveLegId: (id: string | null) => void;
|
||||||
|
setMapCenter: (pos: [number, number]) => void;
|
||||||
|
fetchTour: (id: string) => Promise<void>;
|
||||||
fetchPublicTours: () => Promise<void>;
|
fetchPublicTours: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,23 +34,253 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
legs: [],
|
legs: [],
|
||||||
publicTours: [],
|
publicTours: [],
|
||||||
userRole: null,
|
userRole: null,
|
||||||
|
activeLegId: null,
|
||||||
|
mapCenter: [10.7769, 106.7009],
|
||||||
setTour: (tour) => set({ currentTour: tour }),
|
setTour: (tour) => set({ currentTour: tour }),
|
||||||
updateLegs: (legs) => set({ legs }),
|
updateLegs: (legs) => set({ legs }),
|
||||||
fetchTour: async (id: number) => {
|
setActiveLegId: (id) => set({ activeLegId: id }),
|
||||||
// Giả sử user hiện tại có ID là 1 (Lộc Phạm - OWNER) để test
|
setMapCenter: (pos) => set({ mapCenter: pos }),
|
||||||
const currentUserId = 1;
|
fetchTour: async (id: string) => {
|
||||||
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 data = await response.json();
|
||||||
|
// ...existing role detection...
|
||||||
const role = data.members?.find((m: any) => m.userId === currentUserId)?.role || 'VIEWER_EXTERNAL';
|
let role = 'VIEWER_ONLY';
|
||||||
set({ currentTour: data, legs: data.legs || [], userRole: role });
|
if (token) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
const currentUserId = payload.sub;
|
||||||
|
const participant = data.participants?.find((p: 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 () => {
|
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();
|
const data = await response.json();
|
||||||
set({ publicTours: data });
|
set({ publicTours: data });
|
||||||
},
|
},
|
||||||
optimizeRouting: async () => {
|
createTour: async (tourData: any) => {
|
||||||
// Logic gọi API /api/v1/routing/optimize và cập nhật lại state
|
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),
|
||||||
|
});
|
||||||
|
return await response.json();
|
||||||
|
},
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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) throw new Error('Lỗi khi thêm thành viên');
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour) get().fetchTour(currentTour.id);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
+1
-1
@@ -4,7 +4,7 @@ import react from '@vitejs/plugin-react';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
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,
|
strictPort: true,
|
||||||
host: true,
|
host: true,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user