Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6026c2227a | |||
| 1eed9b00de | |||
| 1709638bfd | |||
| 37b5d14d7e | |||
| 2fabdb79df | |||
| 00554224a1 | |||
| 88b2182789 | |||
| 989d60643b | |||
| 1772ced959 | |||
| c005009da2 | |||
| bfd18e05dd | |||
| 29d39ae7b0 | |||
| 047170d4be | |||
| fc96ee9eb8 | |||
| 0a584a13c7 | |||
| f78c72ad60 | |||
| 8cca4a83ca | |||
| bc49e081c6 | |||
| 9e26aabce6 | |||
| 3e1a5db1a6 | |||
| baa83aad8a | |||
| 302a82e887 | |||
| 63da413b3c | |||
| 02c493f7e0 | |||
| b939fc959d | |||
| 5464d90948 | |||
| c5530f36df | |||
| dcaf71032c | |||
| aec9112064 | |||
| 8dac79fc4b | |||
| 75cf96898c | |||
| f52580717f | |||
| 879f20985c | |||
| 4edfd0eb59 | |||
| 55f0a8e775 | |||
| 7812d1395b | |||
| c9ef98b2ff | |||
| 71f4aa9ecd | |||
| 34e2af8825 | |||
| 4716b841dc | |||
| 967f6b4f6a | |||
| c6341aa12f | |||
| 7cc724a133 | |||
| 8ff09eeeaf | |||
| 914a9cf243 | |||
| d084e806a1 | |||
| bb15b2bf15 | |||
| 4bec095a40 | |||
| 81e715ddb3 | |||
| c6d0fdd5d1 | |||
| 3a29f98c96 | |||
| 71007bd9a1 | |||
| 65a9b39966 | |||
| 58087a4b37 | |||
| 838aec562f | |||
| ec650fb45d | |||
| bf63510980 | |||
| b3519c94cd | |||
| 5cfb5b9d15 | |||
| 4545bde8ec | |||
| 14bf43487e | |||
| ed437cdb0f | |||
| c7db2c3ed8 | |||
| 5e3f10631f | |||
| 578b03d808 | |||
| 25fcd5d926 | |||
| c51ddc34c7 | |||
| b54707823c |
@@ -0,0 +1,8 @@
|
|||||||
|
name: Local Config
|
||||||
|
version: 1.0.0
|
||||||
|
schema: v1
|
||||||
|
models:
|
||||||
|
- name: Autodetect
|
||||||
|
provider: lmstudio
|
||||||
|
model: AUTODETECT
|
||||||
|
apiBase: http://192.168.1.12:1234/v1/
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://app.kilo.ai/config.json"
|
||||||
|
}
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"continue.enableConsole": true
|
||||||
|
}
|
||||||
-120
@@ -1,120 +0,0 @@
|
|||||||
# Kiến Trúc Hệ Thống: Tính Năng Lập Kế Hoạch Du Lịch (Travel Planning Feature)
|
|
||||||
|
|
||||||
Tài liệu này mô tả kiến trúc tổng thể, mô hình dữ liệu và các giải pháp kỹ thuật cho tính năng lập kế hoạch du lịch đa người dùng thuộc nền tảng **Travel**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Kiến Trúc Tổng Quan (System Overview)
|
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
* **Frontend (Presentation Layer):** Đảm nhiệm việc tương tác trực tiếp với người dùng, hiển thị bản đồ trực quan, quản lý luồng tạo tour/chặng và xử lý tải ảnh/hiển thị album.
|
|
||||||
* **Backend (Business Logic Layer):** Xử lý xác thực bảo mật, tính toán chi phí (quy tắc phân chia tiền), định tuyến đường đi ngắn nhất, lập lịch kiểm tra task (Cron job/Event-driven) và kiểm tra quyền truy cập (RBAC).
|
|
||||||
* **Database & Storage (Data Layer):** Lưu trữ dữ liệu quan hệ (PostgreSQL với extension PostGIS để xử lý tọa độ địa lý) và lưu trữ tệp tin hình ảnh riêng biệt (Object Storage như AWS S3 hoặc MinIO).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Công Nghệ Đề Xuất (Tech Stack)
|
|
||||||
|
|
||||||
| Thành phần | Công nghệ đề xuất | Lý do lựa chọn |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| **Frontend** | React / Next.js + TailwindCSS | Tối ưu cho ứng dụng Single Page (SPA), quản lý trạng thái tốt, hỗ trợ SSR cho SEO nếu cần. |
|
|
||||||
| **State Management** | Zustand / Redux Toolkit | Quản lý state phức tạp của Tour, Chặng (Leg) và Chi phí. |
|
|
||||||
| **Map Engine** | Mapbox GL JS / Google Maps API / Leaflet | Hỗ trợ hiển thị bản đồ trực quan, marker kéo thả và tính toán định tuyến (Routing). |
|
|
||||||
| **Backend** | Node.js (NestJS hoặc Express) | Xử lý bất đồng bộ tốt, phù hợp hệ thống nhiều I/O và tích hợp mDNS/IoT nếu mở rộng sau này. |
|
|
||||||
| **Database** | PostgreSQL + **PostGIS** | Hệ quản trị cơ sở dữ liệu mạnh mẽ, PostGIS giúp tính toán khoảng cách và tọa độ địa lý chính xác. |
|
|
||||||
| **File Storage** | AWS S3 / MinIO | Lưu trữ ảnh tách biệt hoàn toàn khỏi Database để tối ưu hiệu năng. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Mô Hình Dữ Liệu (Database Schema)
|
|
||||||
|
|
||||||
Dưới đây là các thực thể cốt lõi phục vụ tính năng:
|
|
||||||
|
|
||||||
### 3.1. Users & Authentication
|
|
||||||
* `users`: Lưu thông tin định danh (`id`, `email`, `password_hash`, `name`, `avatar`).
|
|
||||||
|
|
||||||
### 3.2. Tour & Members (Quản lý đa người dùng & Phân quyền)
|
|
||||||
* `tours`: Thông tin tổng quan về chuyến đi.
|
|
||||||
* `id` (PK), `title`, `start_date`, `end_date`, `creator_id` (FK), `created_at`.
|
|
||||||
* `tour_members`: Bảng trung gian quản lý thành viên và quyền hạn (Bảo mật thông tin).
|
|
||||||
* `tour_id` (FK), `user_id` (FK).
|
|
||||||
* `role`: Định nghĩa các quyền cụ thể:
|
|
||||||
* `OWNER`: Toàn quyền (Người tạo).
|
|
||||||
* `EDITOR`: Sửa đổi kế hoạch, chi phí, xem/thêm ảnh.
|
|
||||||
* `MEMBER_PLAN_ONLY`: Chỉ xem/sửa kế hoạch, không xem được chi phí.
|
|
||||||
* `MEMBER_PHOTO_ONLY`: Chỉ được xem/đăng ảnh trong album, không thấy kế hoạch và chi phí.
|
|
||||||
* `VIEWER_EXTERNAL`: Người ngoài được share link, chỉ xem được ảnh công khai (tùy thuộc vào cài đặt privacy).
|
|
||||||
|
|
||||||
### 3.3. Itinerary & Map (Chặng & Địa điểm)
|
|
||||||
* `legs` (Chặng): Một tour có nhiều chặng.
|
|
||||||
* `id` (PK), `tour_id` (FK), `sequence_number` (Thứ tự chặng: 1, 2, 3...), `notes`.
|
|
||||||
* `places` (Địa điểm trong chặng):
|
|
||||||
* `id` (PK), `leg_id` (FK), `name`, `address`, `latitude`, `longitude` (Dữ liệu PostGIS), `sequence_in_leg`.
|
|
||||||
* `arrival_time` (Dự kiến), `departure_time` (Dự kiến).
|
|
||||||
|
|
||||||
### 3.4. Expenses (Chi phí)
|
|
||||||
* `expenses`: Lưu vết chi phí cho từng địa điểm/chặng.
|
|
||||||
* `id` (PK), `leg_id` (FK), `place_id` (FK, nullable), `category` (`LODGING`, `DINING`, `TRANSPORT`, `OTHER`), `amount` (Số tiền), `currency`, `description`.
|
|
||||||
* `tour_cost_summaries`: Cấu hình phân chia tiền ở cuối tour/chặng.
|
|
||||||
* `tour_id` (FK), `total_cost`, `adult_count`, `child_count`, `child_discount_percent` (Ví dụ: trẻ em giảm 30%).
|
|
||||||
|
|
||||||
### 3.5. Tasks & Timeline Tracking (Theo dõi tiến độ)
|
|
||||||
* `tasks`: Các hoạt động cần tích chọn hoàn thành.
|
|
||||||
* `id` (PK), `tour_id` (FK), `leg_id` (FK), `title`, `planned_timestamp`.
|
|
||||||
* `is_completed` (Boolean).
|
|
||||||
* `completed_at` (Timestamp - Ghi lại lúc hệ thống tự động tích hoặc user chủ động tích).
|
|
||||||
* `trigger_type` (`AUTO_BY_TIME` hoặc `MANUAL_BY_USER`).
|
|
||||||
|
|
||||||
### 3.6. Album Ảnh (Tách biệt logic kế hoạch)
|
|
||||||
* `photos`: Lưu trữ hình ảnh của tour gắn với địa điểm.
|
|
||||||
* `id` (PK), `tour_id` (FK), `place_id` (FK, nullable), `uploader_id` (FK), `image_url` (Đường dẫn S3), `captured_at` (Metadata từ ảnh hoặc thời gian tạo), `privacy_level` (`PUBLIC_IN_TOUR`, `PRIVATE_OWNER`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Giải Pháp Kiến Trúc Cho Các Tính Năng Core
|
|
||||||
|
|
||||||
### 4.1. Thuật Toán Tìm Đường Ngắn Nhất (Shortest Path Routing)
|
|
||||||
* **Cơ chế:** Khi người dùng thêm các địa điểm vào một chặng, Frontend sẽ gửi danh sách tọa độ hình học (Lat, Lng) về Backend.
|
|
||||||
* **Xử lý:** Backend tích hợp với **Mapbox Direction API** hoặc chạy một instance **OSRM (Open Source Routing Machine)** cục bộ để giải bài toán Người đi giao hàng (TSP - Travelling Salesman Problem). Hệ thống sẽ sắp xếp lại thứ tự `sequence_in_leg` của các `places` để tối ưu tổng quãng đường hoặc thời gian di chuyển, sau đó trả về mảng tọa độ đường đi (Polyline) để Frontend vẽ lên bản đồ.
|
|
||||||
|
|
||||||
### 4.2. Engine Tính Toán Chi Phí & Chia Tiền (Expense Splitting Engine)
|
|
||||||
* **Công thức tự động:** * Tổng chi phí Tour ($T$) = $\sum \text{Expenses}$ của tất cả các chặng.
|
|
||||||
* Hệ thống cho phép cấu hình số lượng Người lớn ($N_{adult}$) và Trẻ em ($N_{child}$), kèm phần trăm giảm giá của trẻ em ($P_{child\_discount}$).
|
|
||||||
* Gọi $X$ là chi phí của 1 người lớn:
|
|
||||||
$$T = N_{adult} \times X + N_{child} \times \left( X \times (1 - P_{child\_discount}) \right)$$
|
|
||||||
* Từ đó Backend tự động giải phương trình để tìm ra chi phí trung bình của người lớn ($X$) và trẻ em, hiển thị báo cáo chi tiết ở cuối mỗi chặng và kết thúc tour.
|
|
||||||
|
|
||||||
### 4.3. Theo Dõi Tiến Độ & Tính Toán Độ Lệch Thời Gian (Time Variance Tracking)
|
|
||||||
* **Tự động (Auto-trigger):** Một Worker/Cron-job chạy ngầm mỗi phút quét các `tasks` có cài đặt `AUTO_BY_TIME`. Nếu `current_time >= planned_timestamp` và chưa hoàn thành, hệ thống tự động chuyển `is_completed = true` và ghi nhận `completed_at = planned_timestamp`.
|
|
||||||
* **Thủ công (Manual-trigger):** Khi user bấm tích chọn hoàn thành, hệ thống ghi nhận ngay `completed_at = current_time`.
|
|
||||||
* **Báo cáo thời gian (Time Summary):** Backend tính toán hiệu số:
|
|
||||||
$$\Delta t = \text{completed\_at} - \text{planned\_timestamp}$$
|
|
||||||
Đồng thời group dữ liệu theo loại hoạt động để xuất ra tổng thời gian: *Di chuyển (dựa trên khoảng cách bản đồ), Tham quan (thời gian ở các địa điểm giải trí), Nghỉ ngơi (thời gian ở chỗ nghỉ).*
|
|
||||||
|
|
||||||
### 4.4. Bảo Mật & Tách Biệt Album Ảnh (Photo Album Isolation)
|
|
||||||
* **Tách biệt logic:** Cơ sở dữ liệu ảnh liên kết qua mã `tour_id` và `place_id` nhưng API endpoint kiểm tra quyền (`/api/v1/tours/:id/photos`) hoạt động độc lập với endpoint kế hoạch (`/api/v1/tours/:id/plans`).
|
|
||||||
* **Bảo mật phân quyền (Privacy Middleware):** Trước khi trả về dữ liệu hình ảnh hoặc kế hoạch, hệ thống sẽ chạy qua một tầng Middleware để check quyền trong bảng `tour_members`:
|
|
||||||
```javascript
|
|
||||||
// Giả lập Logic kiểm tra quyền truy cập thông tin tài chính/kế hoạch
|
|
||||||
if (member.role === 'MEMBER_PHOTO_ONLY' && request.path.includes('/plans')) {
|
|
||||||
throw new ForbiddenException("Bạn không có quyền xem kế hoạch và chi phí của tour này.");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Thiết Kế Luồng Đi (Workflow Diagrams)
|
|
||||||
|
|
||||||
### Luồng tạo chặng và tính toán đường đi (Frontend <=> Backend)
|
|
||||||
1. **User** tìm kiếm/chọn địa điểm trên bản đồ -> **Frontend** cập nhật state cục bộ.
|
|
||||||
2. **User** nhấn "Tối ưu đường đi" -> **Frontend** gửi danh sách `coords[]` lên `/api/v1/routing/optimize`.
|
|
||||||
3. **Backend** gọi Mapbox/OSRM API -> Sắp xếp lại thứ tự tối ưu -> Lưu vào DB -> Trả về JSON chứa `optimized_places` và `polyline_geometry`.
|
|
||||||
4. **Frontend** render lại danh sách địa điểm theo thứ tự mới và vẽ nét đứt/đường đi trên Map.
|
|
||||||
|
|
||||||
### Luồng chia sẻ hình ảnh và kiểm tra bảo mật
|
|
||||||
1. **User** tải ảnh lên tại một địa điểm trong Tour.
|
|
||||||
2. **Frontend** gửi file đến dịch vụ lưu trữ (S3) nhận về `image_url`, sau đó lưu thông tin vào DB kèm metadata (ngày giờ, địa điểm).
|
|
||||||
3. Khi một **User khác** truy cập link Tour:
|
|
||||||
* Hệ thống check `tour_members`.
|
|
||||||
* Nếu thuộc diện *External Viewer* hoặc *Photo Only*: Ẩn hoàn toàn tab "Kế hoạch chặng hành trình" và "Tổng số tiền", chỉ hiển thị giao diện Grid hình ảnh (`album`).
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { LandingPage } from './LandingPage.js';
|
|
||||||
import { TourDetailPage } from './TourDetailPage.js';
|
|
||||||
import { ExploreMap } from './ExploreMap.js';
|
|
||||||
import { SignupPage } from './SignupPage.js';
|
|
||||||
|
|
||||||
const App = () => {
|
|
||||||
type View = 'landing' | 'explore' | 'detail' | 'signup';
|
|
||||||
const [view, setView] = useState<View>('landing');
|
|
||||||
const [isInitialSetup, setIsInitialSetup] = useState(false);
|
|
||||||
const [user, setUser] = useState<any>(null);
|
|
||||||
const [isUserLoaded, setIsUserLoaded] = useState(false); // Trạng thái để biết user đã được load từ localStorage chưa
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Tự động xác định địa chỉ IP của Backend dựa trên hostname hiện tại
|
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
|
||||||
|
|
||||||
// Khôi phục phiên đăng nhập từ localStorage
|
|
||||||
const savedUser = localStorage.getItem('user');
|
|
||||||
if (savedUser) {
|
|
||||||
const parsedUser = JSON.parse(savedUser);
|
|
||||||
setUser(parsedUser);
|
|
||||||
}
|
|
||||||
setIsUserLoaded(true); // Đánh dấu user đã được load
|
|
||||||
|
|
||||||
// Kiểm tra xem hệ thống đã được cài đặt chưa
|
|
||||||
fetch(`${API_BASE}/api/v1/auth/status`)
|
|
||||||
.then(res => res.ok ? res.json() : Promise.reject())
|
|
||||||
.then(data => setIsInitialSetup(!!data.isInitialSetup))
|
|
||||||
.catch(() => setIsInitialSetup(false));
|
|
||||||
}, []); // Chạy một lần khi component mount
|
|
||||||
|
|
||||||
// Effect để xử lý chuyển hướng nếu user đã đăng nhập và đang ở trang landing
|
|
||||||
useEffect(() => {
|
|
||||||
if (isUserLoaded && user && view === 'landing') {
|
|
||||||
setView('explore');
|
|
||||||
}
|
|
||||||
}, [isUserLoaded, user, view]);
|
|
||||||
|
|
||||||
const handleLoginSuccess = (userData: any) => {
|
|
||||||
setUser(userData);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
localStorage.removeItem('user');
|
|
||||||
localStorage.removeItem('token');
|
|
||||||
setUser(null);
|
|
||||||
setView('landing');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="app-container">
|
|
||||||
{view === 'landing' && (
|
|
||||||
<LandingPage
|
|
||||||
isInitialSetup={isInitialSetup}
|
|
||||||
onContinue={() => setView('explore')}
|
|
||||||
onGoToSignup={() => setView('signup')}
|
|
||||||
onGoToMap={() => setView('explore')}
|
|
||||||
onLoginSuccess={handleLoginSuccess}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{view === 'signup' && (
|
|
||||||
<SignupPage
|
|
||||||
onBack={() => setView('landing')}
|
|
||||||
onSuccess={() => setView('landing')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{view === 'explore' && (
|
|
||||||
<ExploreMap
|
|
||||||
onBack={() => setView('landing')}
|
|
||||||
onLogout={user ? handleLogout : undefined}
|
|
||||||
user={user}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{view === 'detail' && (
|
|
||||||
<TourDetailPage />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
|
||||||
import { Wallet, Users, Info } from 'lucide-react';
|
|
||||||
import { useTourStore } from './useTourStore.js';
|
|
||||||
|
|
||||||
export const ExpenseManager = () => {
|
|
||||||
const { legs } = useTourStore();
|
|
||||||
const [adults, setAdults] = useState(2);
|
|
||||||
const [children, setChildren] = useState(1);
|
|
||||||
const [discount, setDiscount] = useState(30);
|
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
|
||||||
const totalAmount = legs.reduce((acc, leg) =>
|
|
||||||
acc + leg.expenses.reduce((lAcc: number, exp: any) => lAcc + Number(exp.amount), 0), 0
|
|
||||||
);
|
|
||||||
|
|
||||||
const childRateFactor = 1 - (discount / 100);
|
|
||||||
const weightedCount = adults + (children * childRateFactor);
|
|
||||||
const adultPrice = totalAmount / weightedCount;
|
|
||||||
const childPrice = adultPrice * childRateFactor;
|
|
||||||
|
|
||||||
return {
|
|
||||||
total: totalAmount,
|
|
||||||
adultPrice: Math.round(adultPrice),
|
|
||||||
childPrice: Math.round(childPrice)
|
|
||||||
};
|
|
||||||
}, [legs, adults, children, discount]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4">
|
|
||||||
<div className="bg-white p-6 rounded-2xl border border-gray-100 shadow-sm">
|
|
||||||
<div className="flex items-center gap-2 mb-6 text-blue-600">
|
|
||||||
<Users className="w-5 h-5" />
|
|
||||||
<h3 className="font-bold">Cấu hình thành viên</h3>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-400 block mb-1">Người lớn</label>
|
|
||||||
<input type="number" value={adults} onChange={e => setAdults(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-400 block mb-1">Trẻ em</label>
|
|
||||||
<input type="number" value={children} onChange={e => setChildren(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-400 block mb-1">Giảm trẻ em (%)</label>
|
|
||||||
<input type="number" value={discount} onChange={e => setDiscount(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-blue-600 rounded-2xl p-6 text-white shadow-lg shadow-blue-200">
|
|
||||||
<div className="flex justify-between items-start mb-8">
|
|
||||||
<div>
|
|
||||||
<p className="text-blue-100 text-sm">Tổng chi phí chuyến đi</p>
|
|
||||||
<h2 className="text-3xl font-bold mt-1">{totals.total.toLocaleString()} VND</h2>
|
|
||||||
</div>
|
|
||||||
<Wallet className="w-8 h-8 opacity-20" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4 border-t border-blue-500 pt-6">
|
|
||||||
<div>
|
|
||||||
<p className="text-blue-100 text-xs uppercase tracking-wider font-semibold">Mỗi người lớn</p>
|
|
||||||
<p className="text-xl font-bold">{totals.adultPrice.toLocaleString()}đ</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-blue-100 text-xs uppercase tracking-wider font-semibold">Mỗi trẻ em (-{discount}%)</p>
|
|
||||||
<p className="text-xl font-bold">{totals.childPrice.toLocaleString()}đ</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-gray-400 text-xs px-2">
|
|
||||||
<Info className="w-4 h-4" />
|
|
||||||
<p>Chi phí được tự động tính toán dựa trên hóa đơn của các chặng.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-132
@@ -1,132 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
|
||||||
import L from 'leaflet';
|
|
||||||
import 'leaflet/dist/leaflet.css';
|
|
||||||
import { useTourStore } from './useTourStore.js';
|
|
||||||
import { X, Navigation, Image as ImageIcon, LogOut, Settings } from 'lucide-react';
|
|
||||||
import { UserManagementModal } from './UserManagementModal.js';
|
|
||||||
|
|
||||||
// Fix lỗi icon mặc định của Leaflet
|
|
||||||
const DefaultIcon = L.icon({
|
|
||||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
|
||||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
|
||||||
iconSize: [25, 41],
|
|
||||||
iconAnchor: [12, 41],
|
|
||||||
});
|
|
||||||
L.Marker.prototype.options.icon = DefaultIcon;
|
|
||||||
|
|
||||||
// Component Helper để cập nhật tâm bản đồ khi vị trí người dùng thay đổi
|
|
||||||
function RecenterMap({ position }: { position: [number, number] }) {
|
|
||||||
const map = useMap();
|
|
||||||
useEffect(() => {
|
|
||||||
map.setView(position, map.getZoom());
|
|
||||||
}, [position, map]);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ExploreMap = ({ onBack, onLogout, user }: { onBack: () => void, onLogout?: () => void, user?: any }) => {
|
|
||||||
const { publicTours, fetchPublicTours } = useTourStore();
|
|
||||||
const [userPos, setUserPos] = useState<[number, number]>([10.7769, 106.7009]); // Mặc định TP.HCM
|
|
||||||
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchPublicTours();
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
|
||||||
(pos) => setUserPos([pos.coords.latitude, pos.coords.longitude]),
|
|
||||||
() => console.log("Không thể lấy vị trí người dùng")
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-screen w-full relative">
|
|
||||||
{/* Nút quay lại */}
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<X className="w-6 h-6 text-gray-800" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Nút đăng xuất - Chỉ hiển thị khi có user login */}
|
|
||||||
{onLogout && (
|
|
||||||
<button
|
|
||||||
onClick={onLogout}
|
|
||||||
className="absolute top-6 right-6 z-[1000] bg-white px-4 py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center gap-2 font-bold text-gray-700"
|
|
||||||
>
|
|
||||||
<LogOut className="w-5 h-5" />
|
|
||||||
<span className="hidden sm:inline">Đăng xuất</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Nút quản lý người dùng cho Admin */}
|
|
||||||
{user?.isAdmin && (
|
|
||||||
<button
|
|
||||||
onClick={() => setIsAdminModalOpen(true)}
|
|
||||||
className="absolute top-6 right-40 z-[1000] bg-blue-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center gap-2 font-bold"
|
|
||||||
>
|
|
||||||
<Settings className="w-5 h-5" />
|
|
||||||
<span className="hidden sm:inline">Quản lý hệ thống</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Header Overlay */}
|
|
||||||
<div className="absolute top-6 left-20 z-[1000] bg-white/90 backdrop-blur-md px-6 py-3 rounded-2xl shadow-xl border border-white/20 hidden sm:block">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Navigation className="w-4 h-4 text-blue-600" />
|
|
||||||
<span className="font-bold text-gray-800">Đang khám phá khu vực của bạn</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MapContainer center={userPos} zoom={13} className="h-full w-full">
|
|
||||||
<TileLayer
|
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
||||||
attribution='© OpenStreetMap contributors'
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
|
|
||||||
<RecenterMap position={userPos} />
|
|
||||||
|
|
||||||
{publicTours.map((tour) => {
|
|
||||||
const place = tour.legs?.[0]?.places?.[0];
|
|
||||||
if (!place) return null;
|
|
||||||
|
|
||||||
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Marker
|
|
||||||
key={tour.id}
|
|
||||||
position={[place.latitude, place.longitude]}
|
|
||||||
icon={L.divIcon({
|
|
||||||
className: 'custom-bubble',
|
|
||||||
html: `
|
|
||||||
<div class="relative group">
|
|
||||||
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
|
|
||||||
<img src="${tourImage}" class="w-full h-full object-cover" />
|
|
||||||
</div>
|
|
||||||
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full p-1 border-2 border-white">
|
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
iconSize: [48, 48],
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Popup className="custom-popup">
|
|
||||||
<div className="p-1 max-w-[200px]">
|
|
||||||
<img src={tourImage} className="w-full h-32 object-cover rounded-lg mb-2" />
|
|
||||||
<h4 className="font-bold text-gray-900 leading-tight mb-1">{tour.title}</h4>
|
|
||||||
<button className="text-xs font-bold text-blue-600 flex items-center gap-1">
|
|
||||||
Xem chi tiết hình ảnh <ImageIcon className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Popup>
|
|
||||||
</Marker>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</MapContainer>
|
|
||||||
|
|
||||||
{/* Admin Modal */}
|
|
||||||
<UserManagementModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
|
||||||
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle } from 'lucide-react';
|
|
||||||
import { useTourStore } from './useTourStore.js';
|
|
||||||
|
|
||||||
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
|
|
||||||
if (!actual) return null;
|
|
||||||
|
|
||||||
const diff = differenceInMinutes(parseISO(actual), parseISO(planned));
|
|
||||||
const isLate = diff > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`flex items-center text-xs font-medium mt-1 ${isLate ? 'text-red-500' : 'text-green-600'}`}>
|
|
||||||
{isLate ? <AlertCircle className="w-3 h-3 mr-1" /> : <CheckCircle2 className="w-3 h-3 mr-1" />}
|
|
||||||
<span>
|
|
||||||
{isLate ? `Trễ ${diff} phút` : diff === 0 ? 'Đúng giờ' : `Sớm ${Math.abs(diff)} phút`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ItineraryTimeline = () => {
|
|
||||||
const { legs } = useTourStore();
|
|
||||||
|
|
||||||
const toggleComplete = async (placeId: number) => {
|
|
||||||
// Gọi API PATCH /api/v1/places/:id để cập nhật trạng thái
|
|
||||||
console.log("Toggle status for place:", placeId);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-2xl mx-auto p-4 sm:p-6 bg-gray-50 min-h-screen">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-800 mb-8 px-2">Lộ trình chuyến đi</h2>
|
|
||||||
|
|
||||||
<div className="space-y-8">
|
|
||||||
{legs.map((leg, legIdx) => (
|
|
||||||
<div key={leg.id} className="relative">
|
|
||||||
{/* Leg Header */}
|
|
||||||
<div className="flex items-center mb-4 px-2">
|
|
||||||
<div className="bg-blue-600 text-white text-sm font-bold px-3 py-1 rounded-full shadow-sm">
|
|
||||||
Chặng {leg.sequenceNumber}
|
|
||||||
</div>
|
|
||||||
<div className="ml-4 h-[1px] flex-1 bg-gray-200" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Vertical Line for the whole leg */}
|
|
||||||
<div className="absolute left-6 top-10 bottom-0 w-0.5 bg-gray-200 -z-0" />
|
|
||||||
|
|
||||||
<div className="space-y-6 ml-2">
|
|
||||||
{leg.places.map((place) => (
|
|
||||||
<div key={place.id} className="relative flex group">
|
|
||||||
{/* Timeline Node */}
|
|
||||||
<div className="z-10 mt-1.5 mr-4">
|
|
||||||
<button
|
|
||||||
onClick={() => toggleComplete(place.id)}
|
|
||||||
className={`transition-colors duration-200 ${place.isCompleted ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`}
|
|
||||||
>
|
|
||||||
{place.isCompleted ? (
|
|
||||||
<CheckCircle2 className="w-8 h-8 bg-white rounded-full" />
|
|
||||||
) : (
|
|
||||||
<Circle className="w-8 h-8 bg-white rounded-full fill-white" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Card Content */}
|
|
||||||
<div className={`flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${
|
|
||||||
place.isCompleted ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'
|
|
||||||
}`}>
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<h3 className={`font-semibold text-lg ${place.isCompleted ? 'text-gray-500 line-through' : 'text-gray-800'}`}>
|
|
||||||
{place.name}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center text-sm text-gray-500 mt-1">
|
|
||||||
<MapPin className="w-3 h-3 mr-1" />
|
|
||||||
<span className="truncate max-w-[200px] sm:max-w-md">{place.address}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-right flex flex-col items-end">
|
|
||||||
<div className="flex items-center text-sm font-medium text-blue-600">
|
|
||||||
<Clock className="w-3 h-3 mr-1" />
|
|
||||||
{format(parseISO(place.arrivalTime), 'HH:mm')}
|
|
||||||
</div>
|
|
||||||
{place.isCompleted && place.completedAt && (
|
|
||||||
<div className="text-[10px] text-gray-400 mt-1 italic">
|
|
||||||
Thực tế: {format(parseISO(place.completedAt), 'HH:mm')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Logic tính toán độ lệch thời gian */}
|
|
||||||
<TimeVariance planned={place.arrivalTime} actual={place.completedAt} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{leg.notes && (
|
|
||||||
<p className="ml-14 mt-4 text-sm text-gray-400 italic">
|
|
||||||
* {leg.notes}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { ItineraryTimeline } from './ItineraryTimeline.js';
|
|
||||||
import { ExpenseManager } from './ExpenseManager.js';
|
|
||||||
import { useTourStore } from './useTourStore.js';
|
|
||||||
import {
|
|
||||||
Map as MapIcon,
|
|
||||||
Wallet,
|
|
||||||
Image as ImageIcon,
|
|
||||||
Calendar,
|
|
||||||
Users,
|
|
||||||
ChevronLeft
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
export const TourDetailPage = () => {
|
|
||||||
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo'>('plan');
|
|
||||||
const { currentTour, fetchTour, userRole } = useTourStore();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Gọi ID 1 (Dữ liệu từ file seed) để kiểm tra
|
|
||||||
fetchTour(1);
|
|
||||||
}, [fetchTour]);
|
|
||||||
|
|
||||||
// Định nghĩa các tab dựa trên quyền hạn (RBAC) từ store
|
|
||||||
const tabs = [
|
|
||||||
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: ['OWNER', 'EDITOR', 'MEMBER_PLAN_ONLY'].includes(userRole || '') },
|
|
||||||
{ id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'EDITOR'].includes(userRole || '') },
|
|
||||||
{ id: 'photo', label: 'Ảnh', icon: ImageIcon, visible: true },
|
|
||||||
].filter(t => t.visible);
|
|
||||||
|
|
||||||
// Tự động chuyển tab nếu người dùng không có quyền xem 'plan'
|
|
||||||
useEffect(() => {
|
|
||||||
if (userRole === 'MEMBER_PHOTO_ONLY') {
|
|
||||||
setActiveTab('photo');
|
|
||||||
}
|
|
||||||
}, [userRole]);
|
|
||||||
|
|
||||||
// Mock dữ liệu header (Trong thực tế sẽ lấy từ currentTour)
|
|
||||||
const tourInfo = {
|
|
||||||
title: currentTour?.title || "Hành trình khám phá TP.HCM",
|
|
||||||
date: "20 - 21 Tháng 11, 2023",
|
|
||||||
members: 5,
|
|
||||||
budget: "2.500.000 VND"
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-white">
|
|
||||||
{/* Top Navigation Bar */}
|
|
||||||
<div className="sticky top-0 z-30 bg-white border-b border-gray-100 px-4 py-3 flex items-center justify-between">
|
|
||||||
<button className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
|
||||||
<ChevronLeft className="w-6 h-6 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
<h1 className="text-lg font-bold text-gray-800 truncate px-4">
|
|
||||||
{tourInfo.title}
|
|
||||||
</h1>
|
|
||||||
<div className="w-10" /> {/* Spacer */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tour Header Info */}
|
|
||||||
<div className="bg-blue-600 text-white p-6 pb-20">
|
|
||||||
<div className="max-w-2xl mx-auto space-y-4">
|
|
||||||
<div className="flex flex-wrap gap-4 text-sm opacity-90">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Calendar className="w-4 h-4 mr-1.5" />
|
|
||||||
{tourInfo.date}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Users className="w-4 h-4 mr-1.5" />
|
|
||||||
{tourInfo.members} thành viên
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<span className="text-3xl font-bold">{tourInfo.budget}</span>
|
|
||||||
<span className="text-blue-100 text-sm">dự kiến</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content Area */}
|
|
||||||
<div className="max-w-2xl mx-auto -mt-12 px-4 pb-24">
|
|
||||||
{/* Tab Switcher */}
|
|
||||||
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-1 flex mb-6">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id as any)}
|
|
||||||
className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'bg-blue-50 text-blue-600 shadow-sm'
|
|
||||||
: 'text-gray-400 hover:text-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<tab.icon className={`w-4 h-4 mr-2 ${activeTab === tab.id ? 'animate-pulse' : ''}`} />
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Panels */}
|
|
||||||
<div className="transition-opacity duration-300">
|
|
||||||
{activeTab === 'plan' && (
|
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-4">
|
|
||||||
<ItineraryTimeline />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'expense' && (
|
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-4">
|
|
||||||
<ExpenseManager />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'photo' && (
|
|
||||||
<div className="grid grid-cols-3 gap-2 animate-in fade-in">
|
|
||||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
|
||||||
<div key={i} className="aspect-square bg-gray-100 rounded-lg overflow-hidden relative group">
|
|
||||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
|
||||||
<img
|
|
||||||
src={`https://picsum.photos/seed/${i + 10}/400/400`}
|
|
||||||
alt="Tour photo"
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Floating Action Button (Mobile) */}
|
|
||||||
<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">
|
|
||||||
{activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
import { OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
export declare class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
|
constructor();
|
||||||
|
onModuleInit(): Promise<void>;
|
||||||
|
onModuleDestroy(): Promise<void>;
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
"use strict";
|
||||||
|
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||||
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||||
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||||
|
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||||
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||||
|
};
|
||||||
|
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||||
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.PrismaService = void 0;
|
||||||
|
const common_1 = require("@nestjs/common");
|
||||||
|
const client_1 = require("@prisma/client");
|
||||||
|
const adapter_pg_1 = require("@prisma/adapter-pg");
|
||||||
|
const pg_1 = require("pg");
|
||||||
|
let PrismaService = class PrismaService extends client_1.PrismaClient {
|
||||||
|
constructor() {
|
||||||
|
console.log('--- [PRISMA CHECK] ---');
|
||||||
|
console.log('DATABASE_URL nhận được:', process.env.DATABASE_URL ? 'ĐÃ ĐỌC THÀNH CÔNG ✔️' : 'VẪN BỊ UNDEFINED ❌');
|
||||||
|
console.log('----------------------');
|
||||||
|
const pool = new pg_1.Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
|
const adapter = new adapter_pg_1.PrismaPg(pool);
|
||||||
|
super({ adapter });
|
||||||
|
}
|
||||||
|
async onModuleInit() { await this.$connect(); }
|
||||||
|
async onModuleDestroy() { await this.$disconnect(); }
|
||||||
|
};
|
||||||
|
exports.PrismaService = PrismaService;
|
||||||
|
exports.PrismaService = PrismaService = __decorate([
|
||||||
|
(0, common_1.Injectable)(),
|
||||||
|
__metadata("design:paramtypes", [])
|
||||||
|
], PrismaService);
|
||||||
|
//# sourceMappingURL=prisma.service.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"prisma.service.js","sourceRoot":"","sources":["../../prisma/prisma.service.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA2E;AAC3E,2CAA8C;AAC9C,mDAA8C;AAC9C,2BAA0B;AAGnB,IAAM,aAAa,GAAnB,MAAM,aAAc,SAAQ,qBAAY;IAC7C;QACE,OAAO,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC;QACtC,OAAO,CAAC,GAAG,CAAC,yBAAyB,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC,CAAC,sBAAsB,CAAC,CAAC,CAAC,oBAAoB,CAAC,CAAC;QACjH,OAAO,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC;QACtC,MAAM,IAAI,GAAG,IAAI,SAAI,CAAC,EAAE,gBAAgB,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC,CAAC;QACtE,MAAM,OAAO,GAAG,IAAI,qBAAQ,CAAC,IAAI,CAAC,CAAC;QACnC,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC;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;AAZY,sCAAa;wBAAb,aAAa;IADzB,IAAA,mBAAU,GAAE;;GACA,aAAa,CAYzB"}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
import { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
export declare class AdminGuard implements CanActivate {
|
||||||
|
private prisma;
|
||||||
|
constructor(prisma: PrismaService);
|
||||||
|
canActivate(context: ExecutionContext): Promise<boolean>;
|
||||||
|
}
|
||||||
Vendored
+40
@@ -0,0 +1,40 @@
|
|||||||
|
"use strict";
|
||||||
|
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||||
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||||
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||||
|
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||||
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||||
|
};
|
||||||
|
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||||
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.AdminGuard = void 0;
|
||||||
|
const common_1 = require("@nestjs/common");
|
||||||
|
const prisma_service_1 = require("../../prisma/prisma.service");
|
||||||
|
let AdminGuard = class AdminGuard {
|
||||||
|
constructor(prisma) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
}
|
||||||
|
async canActivate(context) {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
if (!user || !user.id) {
|
||||||
|
throw new common_1.ForbiddenException('Yêu cầu xác thực không hợp lệ. Vui lòng đăng nhập.');
|
||||||
|
}
|
||||||
|
const dbUser = await this.prisma.user.findUnique({
|
||||||
|
where: { id: user.id },
|
||||||
|
select: { isAdmin: true, isBlocked: true },
|
||||||
|
});
|
||||||
|
if (!dbUser || !dbUser.isAdmin || dbUser.isBlocked) {
|
||||||
|
throw new common_1.ForbiddenException('Truy cập bị từ chối. Bạn không có quyền quản trị viên hệ thống.');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
exports.AdminGuard = AdminGuard;
|
||||||
|
exports.AdminGuard = AdminGuard = __decorate([
|
||||||
|
(0, common_1.Injectable)(),
|
||||||
|
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||||
|
], AdminGuard);
|
||||||
|
//# sourceMappingURL=admin.guard.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"admin.guard.js","sourceRoot":"","sources":["../../../src/auth/admin.guard.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA+F;AAC/F,gEAA4D;AAGrD,IAAM,UAAU,GAAhB,MAAM,UAAU;IACrB,YAAoB,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAE7C,KAAK,CAAC,WAAW,CAAC,OAAyB;QACzC,MAAM,OAAO,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,UAAU,EAAE,CAAC;QAGpD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;QAE1B,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC;YACtB,MAAM,IAAI,2BAAkB,CAAC,oDAAoD,CAAC,CAAC;QACrF,CAAC;QAGD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC;YAC/C,KAAK,EAAE,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE;YACtB,MAAM,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE;SAC3C,CAAC,CAAC;QAEH,IAAI,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,IAAI,MAAM,CAAC,SAAS,EAAE,CAAC;YACnD,MAAM,IAAI,2BAAkB,CAAC,iEAAiE,CAAC,CAAC;QAClG,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAzBY,gCAAU;qBAAV,UAAU;IADtB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,UAAU,CAyBtB"}
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
declare const JwtAuthGuard_base: import("@nestjs/passport").Type<import("@nestjs/passport").IAuthGuard>;
|
||||||
|
export declare class JwtAuthGuard extends JwtAuthGuard_base {
|
||||||
|
}
|
||||||
|
export {};
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
"use strict";
|
||||||
|
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||||
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||||
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||||
|
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||||
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.JwtAuthGuard = void 0;
|
||||||
|
const common_1 = require("@nestjs/common");
|
||||||
|
const passport_1 = require("@nestjs/passport");
|
||||||
|
let JwtAuthGuard = class JwtAuthGuard extends (0, passport_1.AuthGuard)('jwt') {
|
||||||
|
};
|
||||||
|
exports.JwtAuthGuard = JwtAuthGuard;
|
||||||
|
exports.JwtAuthGuard = JwtAuthGuard = __decorate([
|
||||||
|
(0, common_1.Injectable)()
|
||||||
|
], JwtAuthGuard);
|
||||||
|
//# sourceMappingURL=jwt-auth.guard.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"jwt-auth.guard.js","sourceRoot":"","sources":["../../../src/auth/jwt-auth.guard.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAA4C;AAC5C,+CAA6C;AAGtC,IAAM,YAAY,GAAlB,MAAM,YAAa,SAAQ,IAAA,oBAAS,EAAC,KAAK,CAAC;CAAG,CAAA;AAAxC,oCAAY;uBAAZ,YAAY;IADxB,IAAA,mBAAU,GAAE;GACA,YAAY,CAA4B"}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
declare const JwtStrategy_base: new (...args: any) => any;
|
||||||
|
export declare class JwtStrategy extends JwtStrategy_base {
|
||||||
|
private prisma;
|
||||||
|
constructor(prisma: PrismaService);
|
||||||
|
validate(payload: any): Promise<{
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
passwordHash: string;
|
||||||
|
name: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
address: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isBlocked: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
export {};
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
"use strict";
|
||||||
|
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||||
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||||
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||||
|
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||||
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||||
|
};
|
||||||
|
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||||
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.JwtStrategy = void 0;
|
||||||
|
const common_1 = require("@nestjs/common");
|
||||||
|
const passport_1 = require("@nestjs/passport");
|
||||||
|
const passport_jwt_1 = require("passport-jwt");
|
||||||
|
const prisma_service_1 = require("../../prisma/prisma.service");
|
||||||
|
let JwtStrategy = class JwtStrategy extends (0, passport_1.PassportStrategy)(passport_jwt_1.Strategy) {
|
||||||
|
constructor(prisma) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: passport_jwt_1.ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: process.env.JWT_SECRET || 'super-secret',
|
||||||
|
});
|
||||||
|
this.prisma = prisma;
|
||||||
|
}
|
||||||
|
async validate(payload) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: payload.sub },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new common_1.UnauthorizedException('Người dùng không tồn tại hoặc phiên làm việc hết hạn');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
exports.JwtStrategy = JwtStrategy;
|
||||||
|
exports.JwtStrategy = JwtStrategy = __decorate([
|
||||||
|
(0, common_1.Injectable)(),
|
||||||
|
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||||
|
], JwtStrategy);
|
||||||
|
//# sourceMappingURL=jwt.strategy.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"jwt.strategy.js","sourceRoot":"","sources":["../../../src/auth/jwt.strategy.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAAmE;AACnE,+CAAoD;AACpD,+CAAoD;AACpD,gEAA4D;AAGrD,IAAM,WAAW,GAAjB,MAAM,WAAY,SAAQ,IAAA,2BAAgB,EAAC,uBAAQ,CAAC;IACzD,YAAoB,MAAqB;QACvC,KAAK,CAAC;YACJ,cAAc,EAAE,yBAAU,CAAC,2BAA2B,EAAE;YACxD,gBAAgB,EAAE,KAAK;YACvB,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,cAAc;SACtD,CAAC,CAAC;QALe,WAAM,GAAN,MAAM,CAAe;IAMzC,CAAC;IAED,KAAK,CAAC,QAAQ,CAAC,OAAY;QACzB,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC;YAC7C,KAAK,EAAE,EAAE,EAAE,EAAE,OAAO,CAAC,GAAG,EAAE;SAC3B,CAAC,CAAC;QAEH,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,MAAM,IAAI,8BAAqB,CAAC,sDAAsD,CAAC,CAAC;QAC1F,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AApBY,kCAAW;sBAAX,WAAW;IADvB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,WAAW,CAoBvB"}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { Cache } from 'cache-manager';
|
||||||
|
import { HttpAdapterHost } from '@nestjs/core';
|
||||||
|
export declare class CompressCacheInterceptor implements NestInterceptor {
|
||||||
|
private cacheManager;
|
||||||
|
private readonly httpAdapterHost;
|
||||||
|
constructor(cacheManager: Cache, httpAdapterHost: HttpAdapterHost);
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"use strict";
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||||
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||||
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||||
|
}
|
||||||
|
Object.defineProperty(o, k2, desc);
|
||||||
|
}) : (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||||
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||||
|
}) : function(o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||||
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||||
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||||
|
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||||
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||||
|
};
|
||||||
|
var __importStar = (this && this.__importStar) || (function () {
|
||||||
|
var ownKeys = function(o) {
|
||||||
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||||
|
var ar = [];
|
||||||
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||||
|
return ar;
|
||||||
|
};
|
||||||
|
return ownKeys(o);
|
||||||
|
};
|
||||||
|
return function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||||
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||||
|
};
|
||||||
|
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
||||||
|
return function (target, key) { decorator(target, key, paramIndex); }
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.CompressCacheInterceptor = void 0;
|
||||||
|
const common_1 = require("@nestjs/common");
|
||||||
|
const rxjs_1 = require("rxjs");
|
||||||
|
const operators_1 = require("rxjs/operators");
|
||||||
|
const cache_manager_1 = require("@nestjs/cache-manager");
|
||||||
|
const zlib = __importStar(require("zlib"));
|
||||||
|
const util_1 = require("util");
|
||||||
|
const core_1 = require("@nestjs/core");
|
||||||
|
const gzip = (0, util_1.promisify)(zlib.gzip);
|
||||||
|
const gunzip = (0, util_1.promisify)(zlib.gunzip);
|
||||||
|
const COMPRESSION_THRESHOLD = 100;
|
||||||
|
let CompressCacheInterceptor = class CompressCacheInterceptor {
|
||||||
|
constructor(cacheManager, httpAdapterHost) {
|
||||||
|
this.cacheManager = cacheManager;
|
||||||
|
this.httpAdapterHost = httpAdapterHost;
|
||||||
|
}
|
||||||
|
async intercept(context, next) {
|
||||||
|
const httpAdapter = this.httpAdapterHost.httpAdapter;
|
||||||
|
const request = context.getArgByIndex(0);
|
||||||
|
const response = context.getArgByIndex(1);
|
||||||
|
if (httpAdapter.getRequestMethod(request) !== 'GET') {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
const cacheKey = httpAdapter.getRequestUrl(request);
|
||||||
|
let cachedData = await this.cacheManager.get(cacheKey);
|
||||||
|
if (cachedData) {
|
||||||
|
try {
|
||||||
|
if (Buffer.isBuffer(cachedData) && cachedData.length > 2 && cachedData[0] === 0x1f && cachedData[1] === 0x8b) {
|
||||||
|
const decompressed = await gunzip(cachedData);
|
||||||
|
const jsonString = decompressed.toString('utf8');
|
||||||
|
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
|
||||||
|
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Compressed)');
|
||||||
|
return (0, rxjs_1.of)(JSON.parse(jsonString));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const jsonString = cachedData.toString();
|
||||||
|
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
|
||||||
|
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Uncompressed)');
|
||||||
|
return (0, rxjs_1.of)(JSON.parse(jsonString));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error(`[Cache] Lỗi giải nén dữ liệu cache cho key ${cacheKey}:`, e);
|
||||||
|
await this.cacheManager.del(cacheKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
httpAdapter.setHeader(response, 'X-Cache', 'MISS');
|
||||||
|
return next.handle().pipe((0, operators_1.tap)(async (data) => {
|
||||||
|
if (!data)
|
||||||
|
return;
|
||||||
|
const jsonString = JSON.stringify(data);
|
||||||
|
const ttl = 60000;
|
||||||
|
if (jsonString.length > COMPRESSION_THRESHOLD) {
|
||||||
|
try {
|
||||||
|
const compressed = await gzip(Buffer.from(jsonString, 'utf8'));
|
||||||
|
await this.cacheManager.set(cacheKey, compressed, ttl);
|
||||||
|
console.log(`[Cache] 📦 Đã nén dữ liệu cho: ${cacheKey} (${jsonString.length} -> ${compressed.length} bytes)`);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error(`[Cache] Lỗi nén dữ liệu cho key ${cacheKey}:`, e);
|
||||||
|
await this.cacheManager.set(cacheKey, jsonString, ttl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await this.cacheManager.set(cacheKey, jsonString, ttl);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
exports.CompressCacheInterceptor = CompressCacheInterceptor;
|
||||||
|
exports.CompressCacheInterceptor = CompressCacheInterceptor = __decorate([
|
||||||
|
(0, common_1.Injectable)(),
|
||||||
|
__param(0, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
|
||||||
|
__metadata("design:paramtypes", [Object, core_1.HttpAdapterHost])
|
||||||
|
], CompressCacheInterceptor);
|
||||||
|
//# sourceMappingURL=compress-cache.interceptor.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"compress-cache.interceptor.js","sourceRoot":"","sources":["../../../src/common/compress-cache.interceptor.ts"],"names":[],"mappings":"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"}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
export declare class TourRoleGuard implements CanActivate {
|
||||||
|
private prisma;
|
||||||
|
constructor(prisma: PrismaService);
|
||||||
|
canActivate(context: ExecutionContext): Promise<boolean>;
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
"use strict";
|
||||||
|
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||||
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||||
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||||
|
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||||
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||||
|
};
|
||||||
|
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||||
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.TourRoleGuard = void 0;
|
||||||
|
const common_1 = require("@nestjs/common");
|
||||||
|
const prisma_service_1 = require("../../prisma/prisma.service");
|
||||||
|
let TourRoleGuard = class TourRoleGuard {
|
||||||
|
constructor(prisma) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
}
|
||||||
|
async canActivate(context) {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
const tourId = request.params.id || request.params.tourId;
|
||||||
|
const path = request.url;
|
||||||
|
if (!user || !tourId) {
|
||||||
|
throw new common_1.ForbiddenException("Thông tin xác thực hoặc mã Tour không hợp lệ.");
|
||||||
|
}
|
||||||
|
if (path.includes('/join-requests') && request.method === 'POST' && (!request.params.requestId || /\/join-requests\/[^/]+\/(accept|reject)$/.test(path))) {
|
||||||
|
request.tourParticipation = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const participation = await this.prisma.tourParticipant.findUnique({
|
||||||
|
where: {
|
||||||
|
tourId_userId: {
|
||||||
|
tourId: tourId,
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!participation) {
|
||||||
|
throw new common_1.ForbiddenException("Bạn không phải là thành viên của tour này.");
|
||||||
|
}
|
||||||
|
request.tourParticipation = participation;
|
||||||
|
const role = participation.role;
|
||||||
|
const isPlanPath = path.includes('/plans');
|
||||||
|
const isExpensePath = path.includes('/expenses');
|
||||||
|
if ((role === 'MEMBER_NO_FINANCE' || role === 'VIEWER_ONLY') &&
|
||||||
|
(isPlanPath || isExpensePath)) {
|
||||||
|
throw new common_1.ForbiddenException("Bạn không có quyền xem kế hoạch và chi phí của tour này.");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
exports.TourRoleGuard = TourRoleGuard;
|
||||||
|
exports.TourRoleGuard = TourRoleGuard = __decorate([
|
||||||
|
(0, common_1.Injectable)(),
|
||||||
|
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||||
|
], TourRoleGuard);
|
||||||
|
//# sourceMappingURL=rbac.middleware.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"rbac.middleware.js","sourceRoot":"","sources":["../../../src/common/rbac.middleware.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA+F;AAC/F,gEAA4D;AAGrD,IAAM,aAAa,GAAnB,MAAM,aAAa;IACxB,YAAoB,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAE7C,KAAK,CAAC,WAAW,CAAC,OAAyB;QACzC,MAAM,OAAO,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,UAAU,EAAE,CAAC;QACpD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;QAE1B,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,EAAE,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC;QAC1D,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC;QAEzB,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YACrB,MAAM,IAAI,2BAAkB,CAAC,+CAA+C,CAAC,CAAC;QAChF,CAAC;QAED,IAAI,IAAI,CAAC,QAAQ,CAAC,gBAAgB,CAAC,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM,IAAI,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,SAAS,IAAI,0CAA0C,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC;YACzJ,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;YACjC,OAAO,IAAI,CAAC;QACd,CAAC;QAED,MAAM,aAAa,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,eAAe,CAAC,UAAU,CAAC;YACjE,KAAK,EAAE;gBACL,aAAa,EAAE;oBACb,MAAM,EAAE,MAAM;oBACd,MAAM,EAAE,IAAI,CAAC,EAAE;iBAChB;aACF;SACF,CAAC,CAAC;QAEH,IAAI,CAAC,aAAa,EAAE,CAAC;YACnB,MAAM,IAAI,2BAAkB,CAAC,4CAA4C,CAAC,CAAC;QAC7E,CAAC;QAED,OAAO,CAAC,iBAAiB,GAAG,aAAa,CAAC;QAE1C,MAAM,IAAI,GAAG,aAAa,CAAC,IAAI,CAAC;QAChC,MAAM,UAAU,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;QAC3C,MAAM,aAAa,GAAG,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;QAEjD,IACE,CAAC,IAAI,KAAK,mBAAmB,IAAI,IAAI,KAAK,aAAa,CAAC;YACxD,CAAC,UAAU,IAAI,aAAa,CAAC,EAC7B,CAAC;YACD,MAAM,IAAI,2BAAkB,CAAC,0DAA0D,CAAC,CAAC;QAC3F,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AA/CY,sCAAa;wBAAb,aAAa;IADzB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,aAAa,CA+CzB"}
|
||||||
Vendored
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { OnGatewayConnection } from '@nestjs/websockets';
|
||||||
|
import { Server, Socket } from 'socket.io';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { ParticipantRole } from '@prisma/client';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { Cache } from 'cache-manager';
|
||||||
|
export declare const ROLES_KEY = "roles";
|
||||||
|
export declare const Roles: (...roles: ParticipantRole[]) => import("@nestjs/common").CustomDecorator<string>;
|
||||||
|
export declare class TourRoleGuard implements CanActivate {
|
||||||
|
private reflector;
|
||||||
|
private prisma;
|
||||||
|
private cacheManager;
|
||||||
|
constructor(reflector: Reflector, prisma: PrismaService, cacheManager: Cache);
|
||||||
|
canActivate(context: ExecutionContext): Promise<boolean>;
|
||||||
|
}
|
||||||
|
export declare class CommentGateway implements OnGatewayConnection {
|
||||||
|
server: Server;
|
||||||
|
handleConnection(client: Socket): void;
|
||||||
|
handleJoinTour(client: Socket, tourId: string): void;
|
||||||
|
notifyNewComment(tourId: string, data: any): void;
|
||||||
|
}
|
||||||
Vendored
+1519
File diff suppressed because it is too large
Load Diff
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -1,8 +1,10 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/nest-cli",
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": ".",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true
|
"deleteOutDir": true,
|
||||||
|
"assets": ["**/*.prisma", "schema.sql"],
|
||||||
|
"watchAssets": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"scripts": {
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"db:generate": "dotenv -e ../.env -- prisma generate --schema=prisma/schema.prisma",
|
||||||
|
"db:migrate": "dotenv -e ../.env -- prisma migrate dev --schema=prisma/schema.prisma",
|
||||||
|
"db:seed": "dotenv -e ../.env -- tsx seed.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^11.0.23",
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/node": "^20.14.10",
|
||||||
|
"@types/pg": "^8.11.6",
|
||||||
|
"dotenv-cli": "^7.4.2",
|
||||||
|
"prisma": "^5.16.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.5.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/cache-manager": "^3.1.3",
|
||||||
|
"@nestjs/common": "^11.1.27",
|
||||||
|
"@nestjs/core": "^11.1.27",
|
||||||
|
"@nestjs/jwt": "^11.0.2",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
|
"@nestjs/platform-express": "^11.1.27",
|
||||||
|
"@nestjs/platform-socket.io": "^11.1.27",
|
||||||
|
"@nestjs/websockets": "^11.1.27",
|
||||||
|
"@prisma/adapter-pg": "^5.16.2",
|
||||||
|
"@prisma/client": "^5.16.2",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"cache-manager": "^7.2.8",
|
||||||
|
"cache-manager-redis-yet": "^5.1.5",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"pg": "^8.12.0",
|
||||||
|
"redis": "^6.0.0",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.2",
|
||||||
|
"sharp": "^0.35.1",
|
||||||
|
"socket.io": "^4.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ParticipantRole" AS ENUM ('OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "JoinRequestStatus" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ExpenseCategory" AS ENUM ('ACCOMMODATION', 'FOOD', 'TRANSPORT', 'TICKET', 'OTHER');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "LocationStatus" AS ENUM ('PENDING', 'COMPLETED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "LocationType" AS ENUM ('MOVE', 'VISIT', 'REST', 'EAT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PrivacyLevel" AS ENUM ('PUBLIC', 'TOUR_ONLY', 'PRIVATE');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"phone" TEXT,
|
||||||
|
"address" TEXT,
|
||||||
|
"avatar" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"isBlocked" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Tour" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"startDate" TIMESTAMP(3),
|
||||||
|
"endDate" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"totalCost" DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
"createdById" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Tour_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "JoinRequest" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tourId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"requestedById" TEXT NOT NULL,
|
||||||
|
"status" "JoinRequestStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "JoinRequest_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TourParticipant" (
|
||||||
|
"tourId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"role" "ParticipantRole" NOT NULL DEFAULT 'MEMBER',
|
||||||
|
|
||||||
|
CONSTRAINT "TourParticipant_pkey" PRIMARY KEY ("tourId","userId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Leg" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tourId" TEXT NOT NULL,
|
||||||
|
"sequence" INTEGER NOT NULL,
|
||||||
|
"note" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Leg_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Location" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"legId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"address" TEXT,
|
||||||
|
"latitude" DOUBLE PRECISION NOT NULL,
|
||||||
|
"longitude" DOUBLE PRECISION NOT NULL,
|
||||||
|
"plannedStart" TIMESTAMP(3),
|
||||||
|
"plannedEnd" TIMESTAMP(3),
|
||||||
|
"actualStart" TIMESTAMP(3),
|
||||||
|
"actualEnd" TIMESTAMP(3),
|
||||||
|
"status" "LocationStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"type" "LocationType" NOT NULL DEFAULT 'VISIT',
|
||||||
|
|
||||||
|
CONSTRAINT "Location_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Expense" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"leg_id" TEXT NOT NULL,
|
||||||
|
"location_id" TEXT,
|
||||||
|
"category" "ExpenseCategory" NOT NULL,
|
||||||
|
"amount" DECIMAL(15,2) NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"note" TEXT,
|
||||||
|
"paid_by_id" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Expense_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Photo" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tourId" TEXT NOT NULL,
|
||||||
|
"locationId" TEXT,
|
||||||
|
"uploaderId" TEXT NOT NULL,
|
||||||
|
"imageUrl" TEXT NOT NULL,
|
||||||
|
"capturedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"metadata" JSONB,
|
||||||
|
"privacy" "PrivacyLevel" NOT NULL DEFAULT 'TOUR_ONLY',
|
||||||
|
|
||||||
|
CONSTRAINT "Photo_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "JoinRequest_tourId_status_idx" ON "JoinRequest"("tourId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "JoinRequest_userId_idx" ON "JoinRequest"("userId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Tour" ADD CONSTRAINT "Tour_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "JoinRequest" ADD CONSTRAINT "JoinRequest_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "JoinRequest" ADD CONSTRAINT "JoinRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "JoinRequest" ADD CONSTRAINT "JoinRequest_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TourParticipant" ADD CONSTRAINT "TourParticipant_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TourParticipant" ADD CONSTRAINT "TourParticipant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Leg" ADD CONSTRAINT "Leg_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Location" ADD CONSTRAINT "Location_legId_fkey" FOREIGN KEY ("legId") REFERENCES "Leg"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_leg_id_fkey" FOREIGN KEY ("leg_id") REFERENCES "Leg"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_location_id_fkey" FOREIGN KEY ("location_id") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_paid_by_id_fkey" FOREIGN KEY ("paid_by_id") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_uploaderId_fkey" FOREIGN KEY ("uploaderId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Leg" ADD COLUMN "description" TEXT,
|
||||||
|
ADD COLUMN "endDate" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "startDate" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Tour" ADD COLUMN "adultCount" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
ADD COLUMN "childCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "childDiscount" INTEGER NOT NULL DEFAULT 30;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Tour" ADD COLUMN "description" TEXT;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Comment" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"locationId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Tour" ADD COLUMN "tags" TEXT[];
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -2,22 +2,18 @@ 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() {
|
||||||
|
console.log('--- [PRISMA CHECK] ---');
|
||||||
|
console.log('DATABASE_URL nhận được:', process.env.DATABASE_URL ? 'ĐÃ ĐỌC THÀNH CÔNG ✔️' : 'VẪN BỊ UNDEFINED ❌');
|
||||||
|
console.log('----------------------');
|
||||||
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,205 @@
|
|||||||
|
// 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"
|
||||||
|
previewFeatures = ["driverAdapters"]
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Enums ---
|
||||||
|
|
||||||
|
enum ParticipantRole {
|
||||||
|
OWNER
|
||||||
|
MANAGER
|
||||||
|
MEMBER
|
||||||
|
MEMBER_NO_FINANCE
|
||||||
|
VIEWER_ONLY
|
||||||
|
}
|
||||||
|
|
||||||
|
enum JoinRequestStatus {
|
||||||
|
PENDING
|
||||||
|
ACCEPTED
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExpenseCategory {
|
||||||
|
ACCOMMODATION
|
||||||
|
FOOD
|
||||||
|
TRANSPORT
|
||||||
|
TICKET
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LocationStatus {
|
||||||
|
PENDING
|
||||||
|
COMPLETED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LocationType {
|
||||||
|
MOVE
|
||||||
|
VISIT
|
||||||
|
REST
|
||||||
|
EAT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PrivacyLevel {
|
||||||
|
PUBLIC
|
||||||
|
TOUR_ONLY
|
||||||
|
PRIVATE
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Models ---
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
name String?
|
||||||
|
phone String?
|
||||||
|
address String?
|
||||||
|
avatar String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
isAdmin Boolean @default(false)
|
||||||
|
isBlocked Boolean @default(false)
|
||||||
|
|
||||||
|
createdTours Tour[] @relation("TourCreator")
|
||||||
|
tourParticipations TourParticipant[]
|
||||||
|
requestedJoinRequests JoinRequest[] @relation("JoinRequestUser")
|
||||||
|
receivedJoinRequests JoinRequest[] @relation("JoinRequester")
|
||||||
|
uploadedPhotos Photo[]
|
||||||
|
paidExpenses Expense[] @relation("ExpensePaidBy")
|
||||||
|
comments Comment[]
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
model Tour {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
startDate DateTime?
|
||||||
|
endDate DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
totalCost Decimal @default(0) @db.Decimal(15, 2)
|
||||||
|
|
||||||
|
adultCount Int @default(1)
|
||||||
|
childCount Int @default(0)
|
||||||
|
childDiscount Int @default(30)
|
||||||
|
|
||||||
|
tags String[]
|
||||||
|
createdById String
|
||||||
|
creator User @relation("TourCreator", fields: [createdById], references: [id])
|
||||||
|
|
||||||
|
participants TourParticipant[]
|
||||||
|
joinRequests JoinRequest[]
|
||||||
|
legs Leg[]
|
||||||
|
photos Photo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model JoinRequest {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tourId String
|
||||||
|
userId String
|
||||||
|
requestedById String
|
||||||
|
status JoinRequestStatus @default(PENDING)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation("JoinRequestUser", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
requestedBy User @relation("JoinRequester", fields: [requestedById], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([tourId, status])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TourParticipant {
|
||||||
|
tourId String
|
||||||
|
userId String
|
||||||
|
role ParticipantRole @default(MEMBER)
|
||||||
|
|
||||||
|
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([tourId, userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Leg {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tourId String
|
||||||
|
sequence Int
|
||||||
|
note String? @db.Text
|
||||||
|
startDate DateTime?
|
||||||
|
endDate DateTime?
|
||||||
|
description 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[]
|
||||||
|
comments Comment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
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?
|
||||||
|
originalUrl String?
|
||||||
|
capturedAt DateTime @default(now())
|
||||||
|
metadata Json?
|
||||||
|
privacy PrivacyLevel @default(TOUR_ONLY)
|
||||||
|
|
||||||
|
tour Tour? @relation(fields: [tourId], references: [id], onDelete: SetNull)
|
||||||
|
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||||
|
uploader User @relation(fields: [uploaderId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Comment {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
content String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
locationId String
|
||||||
|
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
+41
-16
@@ -1,7 +1,12 @@
|
|||||||
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';
|
import * as dotenv from 'dotenv';
|
||||||
|
import * as path from 'path';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
const envPath = path.resolve(process.cwd(), '..', '.env');
|
||||||
|
dotenv.config({ path: envPath });
|
||||||
|
|
||||||
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 +25,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 +35,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 +45,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 +59,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,16 +83,36 @@ 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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('--- Đang tạo bình luận mẫu... ---');
|
||||||
|
const dinhDocLap = await prisma.location.findFirst({ where: { name: 'Dinh Độc Lập' } });
|
||||||
|
if (dinhDocLap) {
|
||||||
|
await prisma.comment.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
content: 'Chỗ này rất đẹp, giàu giá trị lịch sử!',
|
||||||
|
locationId: dinhDocLap.id,
|
||||||
|
userId: owner.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: 'Nên đi vào buổi sáng cho mát mẻ mọi người nhé.',
|
||||||
|
locationId: dinhDocLap.id,
|
||||||
|
userId: photoMember.id,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log('--- Seed dữ liệu hoàn tất! ---');
|
console.log('--- Seed dữ liệu hoàn tất! ---');
|
||||||
console.log(`Email đăng nhập Owner: ${owner.email}`);
|
console.log(`Email đăng nhập Owner: ${owner.email}`);
|
||||||
console.log(`Email đăng nhập Photo Only: ${photoMember.email}`);
|
console.log(`Email đăng nhập Photo Only: ${photoMember.email}`);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||||
import { PrismaService } from './prisma.service.js';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminGuard implements CanActivate {
|
export class AdminGuard implements CanActivate {
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
import { PrismaService } from './prisma.service.js';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Inject } from '@nestjs/common';
|
||||||
|
import { Observable, of } from 'rxjs';
|
||||||
|
import { tap } from 'rxjs/operators';
|
||||||
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||||
|
import { Cache } from 'cache-manager';
|
||||||
|
import * as zlib from 'zlib';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { HttpAdapterHost } from '@nestjs/core';
|
||||||
|
|
||||||
|
// Promisify các hàm nén/giải nén
|
||||||
|
const gzip = promisify(zlib.gzip);
|
||||||
|
const gunzip = promisify(zlib.gunzip);
|
||||||
|
|
||||||
|
// Ngưỡng nén: Chỉ nén nếu chuỗi JSON lớn hơn ngưỡng này (bytes)
|
||||||
|
// Nén dữ liệu quá nhỏ có thể làm tăng kích thước do overhead của header nén
|
||||||
|
const COMPRESSION_THRESHOLD = 100;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CompressCacheInterceptor implements NestInterceptor {
|
||||||
|
constructor(
|
||||||
|
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||||||
|
private readonly httpAdapterHost: HttpAdapterHost, // Để truy cập request/response
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
|
||||||
|
const httpAdapter = this.httpAdapterHost.httpAdapter;
|
||||||
|
const request = context.getArgByIndex(0);
|
||||||
|
const response = context.getArgByIndex(1);
|
||||||
|
|
||||||
|
// Chỉ áp dụng cho các request GET
|
||||||
|
if (httpAdapter.getRequestMethod(request) !== 'GET') {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = httpAdapter.getRequestUrl(request);
|
||||||
|
let cachedData = await this.cacheManager.get<Buffer>(cacheKey);
|
||||||
|
|
||||||
|
if (cachedData) {
|
||||||
|
try {
|
||||||
|
// Kiểm tra xem dữ liệu có phải là Buffer và có Gzip header (0x1f 0x8b) không
|
||||||
|
if (Buffer.isBuffer(cachedData) && cachedData.length > 2 && cachedData[0] === 0x1f && cachedData[1] === 0x8b) {
|
||||||
|
const decompressed = await gunzip(cachedData);
|
||||||
|
const jsonString = decompressed.toString('utf8');
|
||||||
|
|
||||||
|
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
|
||||||
|
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Compressed)');
|
||||||
|
return of(JSON.parse(jsonString));
|
||||||
|
} else {
|
||||||
|
// Dữ liệu không nén (lưu dưới dạng string hoặc buffer thường)
|
||||||
|
const jsonString = cachedData.toString();
|
||||||
|
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
|
||||||
|
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Uncompressed)');
|
||||||
|
return of(JSON.parse(jsonString));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Cache] Lỗi giải nén dữ liệu cache cho key ${cacheKey}:`, e);
|
||||||
|
// Nếu giải nén lỗi, coi như cache miss và xóa cache bị lỗi
|
||||||
|
await this.cacheManager.del(cacheKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss hoặc giải nén lỗi, tiếp tục xử lý request
|
||||||
|
// Đặt header MISS ngay lập tức trước khi chạy logic Controller
|
||||||
|
httpAdapter.setHeader(response, 'X-Cache', 'MISS');
|
||||||
|
|
||||||
|
return next.handle().pipe(
|
||||||
|
tap(async (data) => { // Sử dụng tap để thực hiện side effect (lưu cache) mà không thay đổi dữ liệu gốc
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const jsonString = JSON.stringify(data);
|
||||||
|
const ttl = 60000; // TTL mặc định 1 phút (có thể cấu hình từ CACHE_TTL.DEFAULT)
|
||||||
|
|
||||||
|
if (jsonString.length > COMPRESSION_THRESHOLD) {
|
||||||
|
try {
|
||||||
|
const compressed = await gzip(Buffer.from(jsonString, 'utf8'));
|
||||||
|
await this.cacheManager.set(cacheKey, compressed, ttl);
|
||||||
|
console.log(`[Cache] 📦 Đã nén dữ liệu cho: ${cacheKey} (${jsonString.length} -> ${compressed.length} bytes)`);
|
||||||
|
// Không setHeader ở đây vì response có thể đã gửi xong
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Cache] Lỗi nén dữ liệu cho key ${cacheKey}:`, e);
|
||||||
|
// Nếu nén lỗi, lưu dữ liệu không nén làm fallback
|
||||||
|
await this.cacheManager.set(cacheKey, jsonString, ttl);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Dữ liệu quá nhỏ, lưu không nén
|
||||||
|
await this.cacheManager.set(cacheKey, jsonString, ttl);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||||
import { PrismaService } from './prisma.service.js';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TourRoleGuard implements CanActivate {
|
export class TourRoleGuard implements CanActivate {
|
||||||
@@ -7,22 +7,21 @@ export class TourRoleGuard implements CanActivate {
|
|||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const user = request.user; // Giả sử đã qua AuthGuard (Passport/JWT)
|
const user = request.user;
|
||||||
|
|
||||||
// Lấy tourId từ URL params (:id hoặc :tourId)
|
const tourId = request.params.id || request.params.tourId;
|
||||||
const tourId = parseInt(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ệ.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
if (path.includes('/join-requests') && request.method === 'POST' && (!request.params.requestId || /\/join-requests\/[^/]+\/(accept|reject)$/.test(path))) {
|
||||||
* TỐI ƯU: Chỉ truy vấn Database 1 lần duy nhất để lấy thông tin thành viên.
|
request.tourParticipation = null;
|
||||||
* Chúng ta lưu kết quả vào request object để các interceptor hoặc controller
|
return true;
|
||||||
* 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 +30,18 @@ export class TourRoleGuard implements CanActivate {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!membership) {
|
if (!participation) {
|
||||||
throw new ForbiddenException("Bạn không phải là thành viên của tour này.");
|
throw new ForbiddenException("Bạn không phải là thành viên của tour này.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gắn thông tin vào request để sử dụng ở tầng Controller
|
request.tourParticipation = participation;
|
||||||
request.tourMembership = membership;
|
|
||||||
|
|
||||||
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
|
|
||||||
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.");
|
||||||
+1401
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node", // Đảm bảo module resolution là 'node'
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2021",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": false,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"strictBindCallApply": false,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": false,
|
||||||
|
"resolveJsonModule": true, // Cho phép import các file .json
|
||||||
|
"esModuleInterop": true, // Cho phép cú pháp import/export ES Modules với CommonJS
|
||||||
|
"baseUrl": "./",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 378 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 322 KiB |
@@ -0,0 +1,208 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
// Types for notification modal
|
||||||
|
export interface NotificationModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
type?: 'info' | 'success' | 'warning' | 'error';
|
||||||
|
confirmButtonText?: string;
|
||||||
|
cancelButtonText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon components for different types
|
||||||
|
const Icons = {
|
||||||
|
info: (props: React.SVGProps<SVGSVGElement>) => (
|
||||||
|
<svg {...props} viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<path d="M12 16v-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<path d="M12 8h.01" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
success: (props: React.SVGProps<SVGSVGElement>) => (
|
||||||
|
<svg {...props} viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<path d="M8 12l2.5 2.5L15.5 9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
warning: (props: React.SVGProps<SVGSVGElement>) => (
|
||||||
|
<svg {...props} viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<circle cx="12" cy="17" r="1" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
error: (props: React.SVGProps<SVGSVGElement>) => (
|
||||||
|
<svg {...props} viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default props
|
||||||
|
const defaultProps: Partial<NotificationModalProps> = {
|
||||||
|
title: 'Thông báo',
|
||||||
|
type: 'info',
|
||||||
|
confirmButtonText: 'OK',
|
||||||
|
cancelButtonText: 'Hủy',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NotificationModal: React.FC<NotificationModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
title = defaultProps.title,
|
||||||
|
message,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
type = defaultProps.type,
|
||||||
|
confirmButtonText = defaultProps.confirmButtonText,
|
||||||
|
cancelButtonText = defaultProps.cancelButtonText,
|
||||||
|
}) => {
|
||||||
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
|
|
||||||
|
// Get colors based on type
|
||||||
|
const getTypeStyles = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'success':
|
||||||
|
return { bg: '#d4edda', border: '#c3e6cb', text: '#155724' };
|
||||||
|
case 'warning':
|
||||||
|
return { bg: '#fff3cd', border: '#ffeeba', text: '#856404' };
|
||||||
|
case 'error':
|
||||||
|
return { bg: '#f8d7da', border: '#f5c6cb', text: '#721c24' };
|
||||||
|
default:
|
||||||
|
return { bg: '#e2e3e5', border: '#d6d8db', text: '#383d41' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = getTypeStyles();
|
||||||
|
|
||||||
|
// Animation classes based on state
|
||||||
|
const getAnimationClass = () => {
|
||||||
|
if (!isOpen) return 'opacity-0 translate-y-4';
|
||||||
|
if (isAnimating && onCancel) return 'animate-fade-out';
|
||||||
|
return 'animate-fade-in';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle confirm click
|
||||||
|
const handleConfirm = () => {
|
||||||
|
setIsAnimating(true);
|
||||||
|
onConfirm?.();
|
||||||
|
setTimeout(() => setIsAnimating(false), 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle cancel click
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsAnimating(true);
|
||||||
|
onCancel?.();
|
||||||
|
setTimeout(() => setIsAnimating(false), 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[100] p-4">
|
||||||
|
<div
|
||||||
|
className={`bg-white rounded-lg shadow-xl max-w-md w-full transform transition-all duration-300 ${getAnimationClass()}`}
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="modal-title"
|
||||||
|
aria-describedby="modal-message"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={`p-6 border-b ${styles.border}`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{type === 'success' && <Icons.success className="w-5 h-5 text-green-600" />}
|
||||||
|
{type === 'warning' && <Icons.warning className="w-5 h-5 text-yellow-600" />}
|
||||||
|
{type === 'error' && <Icons.error className="w-5 h-5 text-red-600" />}
|
||||||
|
{type === 'info' && <Icons.info className="w-5 h-5 text-blue-600" />}
|
||||||
|
<h2 id="modal-title" className={`text-xl font-semibold ${styles.text}`}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="p-6">
|
||||||
|
<p id="modal-message" className="text-gray-700 leading-relaxed">{message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className={`px-6 py-4 flex justify-end gap-3 border-t ${styles.border}`}>
|
||||||
|
{onCancel && (
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
{cancelButtonText}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onConfirm && (
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className={`px-4 py-2 text-white rounded-md font-medium transition-colors ${
|
||||||
|
type === 'error'
|
||||||
|
? 'bg-red-600 hover:bg-red-700'
|
||||||
|
: 'bg-blue-600 hover:bg-blue-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmButtonText}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for easy usage without props management
|
||||||
|
export const useNotificationModal = () => {
|
||||||
|
const [modalState, setModalState] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
type?: 'info' | 'success' | 'warning' | 'error';
|
||||||
|
onConfirm?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const openModal = (
|
||||||
|
title: string,
|
||||||
|
message: string,
|
||||||
|
type: 'info' | 'success' | 'warning' | 'error' = 'info',
|
||||||
|
onConfirm?: () => void,
|
||||||
|
onCancel?: () => void,
|
||||||
|
) => {
|
||||||
|
setModalState({
|
||||||
|
isOpen: true,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-close after 5 seconds if no confirm action
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (onCancel) {
|
||||||
|
setModalState((prev) => ({ ...prev, isOpen: false }));
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setModalState((prev) => prev ? { ...prev, isOpen: false } : null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
modalState,
|
||||||
|
openModal,
|
||||||
|
closeModal,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationModal;
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
import React from 'react';
|
||||||
|
export declare const AddLocationModal: ({ isOpen, onClose, tourId, initialLegId, editingLocation }: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
tourId: string;
|
||||||
|
initialLegId?: string;
|
||||||
|
editingLocation?: any;
|
||||||
|
}) => React.JSX.Element;
|
||||||
Vendored
+146
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+31
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
interface AddMemberModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
tourId: string;
|
||||||
|
participants?: Array<{
|
||||||
|
userId: string;
|
||||||
|
role: string;
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
joinRequests?: Array<{
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
status: string;
|
||||||
|
requestedById: string;
|
||||||
|
}>;
|
||||||
|
onRemoveMember?: (userId: string) => Promise<void>;
|
||||||
|
onMemberAdded?: () => void;
|
||||||
|
userRole?: string;
|
||||||
|
}
|
||||||
|
export declare const AddMemberModal: React.FC<AddMemberModalProps>;
|
||||||
|
export {};
|
||||||
Vendored
+154
@@ -0,0 +1,154 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { X, UserPlus, Loader2, Shield, Trash2, Clock } from 'lucide-react';
|
||||||
|
export const AddMemberModal = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole }) => {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState(null);
|
||||||
|
const [role, setRole] = useState('MEMBER');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [fetchError, setFetchError] = useState('');
|
||||||
|
const [submitError, setSubmitError] = useState('');
|
||||||
|
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
||||||
|
const [confirmTarget, setConfirmTarget] = useState(null);
|
||||||
|
const [actionLoading, setActionLoading] = useState(null);
|
||||||
|
const participantIds = useMemo(() => new Set(participants.map((p) => p.userId)), [participants]);
|
||||||
|
const requestUserIds = useMemo(() => new Set(joinRequests.map((r) => r.userId)), [joinRequests]);
|
||||||
|
const visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]);
|
||||||
|
const canCreateDirectly = !userRole || userRole === 'OWNER' || userRole === 'MANAGER';
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setFetchError('');
|
||||||
|
try {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/users?q=${encodeURIComponent(query)}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||||
|
});
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error('Không thể tải danh sách người dùng');
|
||||||
|
const data = await res.json();
|
||||||
|
setUsers(Array.isArray(data) ? data : []);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
setFetchError(err.message || 'Không thể tải danh sách người dùng');
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen)
|
||||||
|
return;
|
||||||
|
fetchUsers();
|
||||||
|
}, [isOpen]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setQuery('');
|
||||||
|
setSelectedUser(null);
|
||||||
|
setRole('MEMBER');
|
||||||
|
setFetchError('');
|
||||||
|
setSubmitError('');
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
const handleRemove = async (userId, memberName) => {
|
||||||
|
if (!onRemoveMember)
|
||||||
|
return;
|
||||||
|
setConfirmTarget({ userId, name: memberName });
|
||||||
|
setIsConfirmOpen(true);
|
||||||
|
};
|
||||||
|
const confirmRemove = async () => {
|
||||||
|
if (!confirmTarget || !onRemoveMember)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
await onRemoveMember(confirmTarget.userId);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
setSubmitError(err.message || 'Không thể xóa thành viên');
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setIsConfirmOpen(false);
|
||||||
|
setConfirmTarget(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleRequestAction = async (reqId, action, userName) => {
|
||||||
|
if (!onMemberAdded)
|
||||||
|
return;
|
||||||
|
setActionLoading(reqId);
|
||||||
|
try {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const endpoint = action === 'accept'
|
||||||
|
? `${API_BASE}/api/v1/tours/${tourId}/join-requests/${reqId}/accept`
|
||||||
|
: `${API_BASE}/api/v1/tours/${tourId}/join-requests/${reqId}/reject`;
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.message || data.error || (action === 'accept' ? 'Không thể chấp nhận' : 'Không thể từ chối'));
|
||||||
|
}
|
||||||
|
await onMemberAdded();
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
alert(err.message || 'Thao tác thất bại');
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!selectedUser)
|
||||||
|
return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setSubmitError('');
|
||||||
|
try {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const endpoint = canCreateDirectly ? `/api/v1/tours/${tourId}/members` : `/api/v1/tours/${tourId}/join-requests`;
|
||||||
|
const body = canCreateDirectly
|
||||||
|
? { userId: selectedUser, role }
|
||||||
|
: { userId: selectedUser };
|
||||||
|
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || data.error || 'Thao tác thất bại');
|
||||||
|
}
|
||||||
|
await onMemberAdded?.();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
setSubmitError(err.message || 'Thao tác thất bại');
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!isOpen)
|
||||||
|
return null;
|
||||||
|
return (_jsxs("div", { className: "fixed inset-0 z-[2000] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/60 backdrop-blur-sm", onClick: onClose }), _jsxs("div", { className: "relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]", children: [_jsxs("div", { className: "p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50", children: [_jsxs("div", { children: [_jsxs("h2", { className: "text-lg font-bold text-gray-900 flex items-center gap-2", children: [_jsx(UserPlus, { className: "w-5 h-5 text-blue-600" }), " ", canCreateDirectly ? 'Thêm thành viên' : 'Mời tham gia tour'] }), _jsx("p", { className: "text-xs text-gray-500", children: canCreateDirectly ? 'Chọn người dùng và phân quyền cho tour này.' : 'Mời sẽ được gửi và chờ người quản lý phê duyệt.' })] }), _jsx("button", { onClick: onClose, className: "p-2 hover:bg-gray-200 rounded-full transition-colors", children: _jsx(X, { className: "w-5 h-5 text-gray-400" }) })] }), _jsxs("div", { className: "p-5 space-y-4", children: [_jsxs("div", { children: [_jsxs("p", { className: "text-[11px] font-bold text-gray-500 mb-2", children: ["Th\u00E0nh vi\u00EAn c\u1EE7a tour (", participants.length, ")"] }), _jsxs("div", { className: "flex flex-wrap gap-3", children: [participants.map((p) => {
|
||||||
|
const rawToken = localStorage.getItem('token');
|
||||||
|
let currentUserId = null;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob((rawToken || '').split('.')[1]));
|
||||||
|
currentUserId = payload.sub;
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
currentUserId = null;
|
||||||
|
}
|
||||||
|
const isCurrentUser = currentUserId && p.userId === currentUserId;
|
||||||
|
const isOwner = p.role === 'OWNER';
|
||||||
|
const canRemove = onRemoveMember && !isCurrentUser && !isOwner;
|
||||||
|
return (_jsxs("div", { className: "flex flex-col items-center gap-1", children: [_jsxs("div", { className: "relative", children: [_jsx("div", { className: "w-10 h-10 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden", children: p.user?.name?.charAt(0) || '?' }), canRemove && (_jsx("button", { onClick: () => handleRemove(p.userId, p.user?.name || p.userId), className: "absolute -top-1 -right-1 w-4 h-4 bg-gray-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white", "aria-label": "Remove item", children: _jsx(Trash2, { size: 10 }) }))] }), _jsx("span", { className: "text-[10px] font-semibold text-gray-700 max-w-[72px] truncate", children: p.user?.name || p.userId })] }, p.userId));
|
||||||
|
}), participants.length === 0 && (_jsx("span", { className: "text-xs text-gray-400", children: "Ch\u01B0a c\u00F3 th\u00E0nh vi\u00EAn n\u00E0o" }))] })] }), joinRequests.length > 0 && (_jsxs("div", { children: [_jsxs("p", { className: "text-[11px] font-bold text-gray-500 mb-2 flex items-center gap-1", children: [_jsx(Clock, { className: "w-3 h-3 text-amber-500" }), " \u0110ang ch\u1EDD ph\u00EA duy\u1EC7t (", joinRequests.length, ")"] }), _jsx("div", { className: "flex flex-wrap gap-3", children: joinRequests.map((req) => (_jsxs("div", { className: "flex flex-col items-center gap-1 relative", children: [_jsx("div", { className: "w-10 h-10 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-600 font-bold text-sm overflow-hidden", children: req.user?.name?.charAt(0) || '?' }), _jsxs("div", { className: "absolute -top-1 -right-1 flex", children: [_jsx("button", { type: "button", disabled: actionLoading === req.id, onClick: () => handleRequestAction(req.id, 'accept', req.user?.name || req.userId), className: "w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50", "aria-label": "Accept", children: "+" }), _jsx("button", { type: "button", disabled: actionLoading === req.id, onClick: () => handleRequestAction(req.id, 'reject', req.user?.name || req.userId), className: "w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1", "aria-label": "Reject", children: "x" })] }), _jsx("span", { className: "text-[10px] font-semibold text-gray-700 max-w-[72px] truncate", children: req.user?.name || req.userId }), _jsx("span", { className: "text-[9px] font-bold text-amber-600 bg-amber-100 px-1.5 py-0.5 rounded-full border border-amber-200", children: "PENDING" })] }, req.id))) })] })), canCreateDirectly && (_jsxs("div", { className: "space-y-1.5", children: [_jsx("label", { className: "text-xs font-bold text-gray-700 ml-1", children: "Ph\u00E2n quy\u1EC1n" }), _jsxs("select", { className: "w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm", value: role, onChange: (e) => setRole(e.target.value), children: [_jsx("option", { value: "OWNER", children: "OWNER" }), _jsx("option", { value: "MANAGER", children: "MANAGER" }), _jsx("option", { value: "MEMBER", children: "MEMBER" }), _jsx("option", { value: "MEMBER_NO_FINANCE", children: "MEMBER_NO_FINANCE" }), _jsx("option", { value: "VIEWER_ONLY", children: "VIEWER_ONLY" })] })] })), _jsxs("div", { className: "space-y-2", children: [fetchError && (_jsx("div", { className: "p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100", children: fetchError })), submitError && (_jsx("div", { className: "p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100", children: submitError })), loading ? (_jsx("div", { className: "flex justify-center py-10", children: _jsx(Loader2, { className: "w-8 h-8 animate-spin text-blue-600" }) })) : (_jsxs("div", { className: "space-y-2 max-h-[40vh] overflow-y-auto pr-1", children: [visibleUsers.map((u) => {
|
||||||
|
const isSelected = selectedUser === u.id;
|
||||||
|
return (_jsxs("button", { onClick: () => setSelectedUser(u.id), disabled: requestUserIds.has(u.id), className: `w-full flex items-center gap-3 p-3 rounded-2xl border transition-all ${isSelected ? 'border-blue-500 bg-blue-50/60' : 'border-gray-100 hover:border-blue-200'} ${requestUserIds.has(u.id) ? 'opacity-60' : ''}`, children: [_jsx("div", { className: "w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold", children: u.name?.charAt(0) || '?' }), _jsxs("div", { className: "flex-1 text-left", children: [_jsx("div", { className: "text-sm font-bold text-gray-900", children: u.name || 'Chưa đặt tên' }), _jsx("div", { className: "text-[11px] text-gray-500", children: u.email }), _jsxs("div", { className: "text-[11px] text-gray-400", children: [u.phone || '', " ", u.address ? `• ${u.address}` : ''] })] }), _jsxs("div", { className: "flex items-center gap-1 text-[10px] font-bold text-gray-500", children: [u.isAdmin ? (_jsx("span", { className: "px-2 py-1 bg-purple-50 text-purple-600 rounded-md border border-purple-100", children: "ADMIN" })) : (_jsx("span", { className: "px-2 py-1 bg-gray-50 text-gray-500 rounded-md border border-gray-100", children: "USER" })), isSelected && _jsx(Shield, { className: "w-3 h-3 text-blue-600" })] })] }, u.id));
|
||||||
|
}), !loading && visibleUsers.length === 0 && (_jsx("div", { className: "text-center py-8 text-sm text-gray-500", children: "Kh\u00F4ng t\u00ECm th\u1EA5y ng\u01B0\u1EDDi d\u00F9ng ph\u00F9 h\u1EE3p" }))] }))] })] }), _jsxs("div", { className: "p-5 border-t border-gray-100 flex justify-end gap-2", children: [_jsx("button", { onClick: onClose, className: "px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors", children: "H\u1EE7y" }), _jsx("button", { disabled: !selectedUser || submitting, onClick: handleAdd, className: "px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-xl text-sm font-bold transition-all", children: submitting ? 'Đang xử lý...' : canCreateDirectly ? 'Thêm vào tour' : 'Gửi lời mời' })] })] }), isConfirmOpen && (_jsxs("div", { className: "fixed inset-0 z-[2100] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/70 backdrop-blur-sm", onClick: () => setIsConfirmOpen(false) }), _jsxs("div", { className: "relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5", children: [_jsx("h3", { className: "text-base font-bold text-gray-900", children: "X\u00E1c nh\u1EADn x\u00F3a th\u00E0nh vi\u00EAn" }), _jsxs("p", { className: "mt-2 text-sm text-gray-600", children: ["B\u1EA1n c\u00F3 ch\u1EAFc mu\u1ED1n x\u00F3a ", _jsx("span", { className: "font-semibold text-gray-800", children: confirmTarget?.name }), " kh\u1ECFi tour n\u00E0y?"] }), _jsxs("div", { className: "mt-4 flex justify-end gap-2", children: [_jsx("button", { onClick: () => setIsConfirmOpen(false), className: "px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors", children: "H\u1EE7y" }), _jsx("button", { onClick: confirmRemove, className: "px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors", children: "X\u00F3a" })] })] })] }))] }));
|
||||||
|
};
|
||||||
|
//# sourceMappingURL=AddMemberModal.js.map
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+6
-1
@@ -4,11 +4,13 @@ import { LandingPage } from './LandingPage.js';
|
|||||||
import { TourDetailPage } from './TourDetailPage.js';
|
import { 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":"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"}
|
{"version":3,"file":"App.js","sourceRoot":"","sources":["../App.tsx"],"names":[],"mappings":"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"}
|
||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react';
|
||||||
|
interface ConfirmModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
export declare const ConfirmModal: React.FC<ConfirmModalProps>;
|
||||||
|
export {};
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
export const ConfirmModal = ({ isOpen, title = 'Xác nhận', message, confirmText = 'Xác nhận', cancelText = 'Hủy', onConfirm, onCancel, }) => {
|
||||||
|
if (!isOpen)
|
||||||
|
return null;
|
||||||
|
return (_jsxs("div", { className: "fixed inset-0 z-[2200] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/70 backdrop-blur-sm", onClick: onCancel }), _jsxs("div", { className: "relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5", children: [_jsx("h3", { className: "text-base font-bold text-gray-900", children: title }), _jsx("p", { className: "mt-2 text-sm text-gray-600", children: message }), _jsxs("div", { className: "mt-4 flex justify-end gap-2", children: [_jsx("button", { onClick: onCancel, className: "px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors", children: cancelText }), _jsx("button", { onClick: onConfirm, className: "px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors", children: confirmText })] })] })] }));
|
||||||
|
};
|
||||||
|
//# sourceMappingURL=ConfirmModal.js.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"ConfirmModal.js","sourceRoot":"","sources":["../ConfirmModal.tsx"],"names":[],"mappings":";AAaA,MAAM,CAAC,MAAM,YAAY,GAAgC,CAAC,EACxD,MAAM,EACN,KAAK,GAAG,UAAU,EAClB,OAAO,EACP,WAAW,GAAG,UAAU,EACxB,UAAU,GAAG,KAAK,EAClB,SAAS,EACT,QAAQ,GACT,EAAE,EAAE;IACH,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAC;IAEzB,OAAO,CACL,eAAK,SAAS,EAAC,6DAA6D,aAC1E,cAAK,SAAS,EAAC,kDAAkD,EAAC,OAAO,EAAE,QAAQ,GAAI,EACvF,eAAK,SAAS,EAAC,8DAA8D,aAC3E,aAAI,SAAS,EAAC,mCAAmC,YAAE,KAAK,GAAM,EAC9D,YAAG,SAAS,EAAC,4BAA4B,YAAE,OAAO,GAAK,EACvD,eAAK,SAAS,EAAC,6BAA6B,aAC1C,iBAAQ,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAC,0FAA0F,YAC5H,UAAU,GACJ,EACT,iBAAQ,OAAO,EAAE,SAAS,EAAE,SAAS,EAAC,iGAAiG,YACpI,WAAW,GACL,IACL,IACF,IACF,CACP,CAAC;AACJ,CAAC,CAAC"}
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
import React from 'react';
|
||||||
|
export declare const CreateTourModal: ({ isOpen, onClose, onSuccess }: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: (tour: any) => void;
|
||||||
|
}) => React.JSX.Element;
|
||||||
Vendored
+66
@@ -0,0 +1,66 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
import { useTourStore } from './useTourStore.js';
|
||||||
|
export const CreateTourModal = ({ isOpen, onClose, onSuccess }) => {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [endDate, setEndDate] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const createTour = useTourStore((state) => state.createTour);
|
||||||
|
const [members, setMembers] = useState([]);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [results, setResults] = useState([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
if (!isOpen)
|
||||||
|
return null;
|
||||||
|
const searchUsers = async (value) => {
|
||||||
|
setQuery(value);
|
||||||
|
if (!value.trim()) {
|
||||||
|
setResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/users?q=${encodeURIComponent(value)}`, {
|
||||||
|
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
|
||||||
|
});
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error('Không thể tải người dùng');
|
||||||
|
const data = await res.json();
|
||||||
|
setResults(Array.isArray(data) ? data : []);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
setResults([]);
|
||||||
|
setError(e.message || 'Không thể tải người dùng');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const confirmAddMember = (user) => {
|
||||||
|
setMembers((prev) => (prev.some((m) => m.id === user.id) ? prev : [...prev, user]));
|
||||||
|
setQuery('');
|
||||||
|
setResults([]);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
const removeMember = (userId) => {
|
||||||
|
setMembers((prev) => prev.filter((m) => m.id !== userId));
|
||||||
|
};
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const memberIds = members.map((m) => m.id);
|
||||||
|
const tour = await createTour({ title, startDate, endDate, memberIds });
|
||||||
|
onSuccess(tour);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
setError(e.message || 'Lỗi khi tạo tour');
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (_jsxs("div", { className: "fixed inset-0 z-[2000] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/60 backdrop-blur-sm", onClick: onClose }), _jsxs("div", { className: "relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden p-6", children: [_jsxs("div", { className: "flex justify-between items-center mb-4", children: [_jsx("h2", { className: "text-xl font-bold text-gray-900", children: "T\u1EA1o Tour m\u1EDBi" }), _jsx("button", { onClick: onClose, className: "p-2 hover:bg-gray-100 rounded-full transition-colors", children: "\u2715" })] }), _jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [_jsxs("div", { children: [_jsx("label", { className: "block text-sm font-bold text-gray-700 mb-1", children: "T\u00EAn Tour" }), _jsx("input", { required: true, className: "w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500", value: title, onChange: (e) => setTitle(e.target.value), placeholder: "VD: Kh\u00E1m ph\u00E1 \u0110\u00E0 L\u1EA1t" })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { children: [_jsx("label", { className: "block text-sm font-bold text-gray-700 mb-1", children: "B\u1EAFt \u0111\u1EA7u" }), _jsx("input", { type: "date", className: "w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500", value: startDate, onChange: (e) => setStartDate(e.target.value) })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-sm font-bold text-gray-700 mb-1", children: "K\u1EBFt th\u00FAc" }), _jsx("input", { type: "date", className: "w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500", value: endDate, onChange: (e) => setEndDate(e.target.value) })] })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-sm font-bold text-gray-700 mb-2", children: "Th\u00E0nh vi\u00EAn tham gia" }), _jsxs("div", { className: "flex flex-wrap gap-3", children: [members.map((m) => (_jsxs("div", { className: "relative", children: [_jsx("div", { className: "w-12 h-12 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden", children: m.name }), _jsx("button", { type: "button", onClick: () => removeMember(m.id), className: "absolute -top-1 -right-1 w-5 h-5 rounded-full bg-gray-500 hover:bg-red-600 text-white flex items-center justify-center transition-colors", "aria-label": "Remove item", children: _jsx(Trash2, { size: 12 }) }), _jsx("div", { className: "text-[10px] text-center mt-1 max-w-[70px] truncate", children: m.name })] }, m.id))), _jsxs("div", { className: "relative", children: [_jsx("input", { className: "w-36 px-2 py-1 text-sm border border-gray-200 rounded-lg outline-none", placeholder: "T\u00ECm email...", value: query, onChange: (e) => searchUsers(e.target.value) }), results.length > 0 && (_jsx("div", { className: "absolute top-full left-0 right-0 mt-2 bg-white border border-gray-100 rounded-xl shadow-lg z-10 max-h-60 overflow-y-auto", children: results.map((u) => (_jsxs("button", { type: "button", onClick: () => confirmAddMember(u), className: "w-full text-left px-3 py-2 text-sm hover:bg-blue-50", children: [_jsx("span", { className: "font-bold text-gray-900", children: u.name }), _jsx("span", { className: "block text-xs text-gray-500", children: u.email })] }, u.id))) }))] })] }), error && _jsx("p", { className: "text-xs text-red-600 mt-2", children: error })] }), _jsx("button", { type: "submit", disabled: isLoading, className: "w-full py-3 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl shadow-lg transition-all flex items-center justify-center gap-2", children: isLoading ? 'Đang tạo...' : 'Xác nhận tạo Tour' })] })] })] }));
|
||||||
|
};
|
||||||
|
//# sourceMappingURL=CreateTourModal.js.map
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-1
@@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import 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
+74
-27
@@ -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,77 @@ 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)
|
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
|
||||||
return null;
|
const markerPos = startLoc
|
||||||
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
|
? [startLoc.latitude, startLoc.longitude]
|
||||||
return (_jsx(Marker, { position: [place.latitude, place.longitude], icon: L.divIcon({
|
: userPos;
|
||||||
className: 'custom-bubble',
|
return (_jsx(React.Fragment, { children: _jsx(Marker, { position: markerPos, eventHandlers: {
|
||||||
html: `
|
click: () => onViewTour(tour.id)
|
||||||
<div class="relative group">
|
}, icon: L.divIcon({
|
||||||
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
|
className: 'custom-bubble',
|
||||||
<img src="${tourImage}" class="w-full h-full object-cover" />
|
html: `
|
||||||
</div>
|
<div class="relative group">
|
||||||
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full p-1 border-2 border-white">
|
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
|
||||||
<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>
|
<img src="${tourImage}" class="w-full h-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
|
||||||
`,
|
S
|
||||||
iconSize: [48, 48],
|
</div>
|
||||||
}), 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));
|
</div>
|
||||||
})] }), _jsx(UserManagementModal, { isOpen: isAdminModalOpen, onClose: () => setIsAdminModalOpen(false) })] }));
|
`,
|
||||||
|
iconSize: [48, 48],
|
||||||
|
iconAnchor: [24, 24]
|
||||||
|
}) }) }, tour.id));
|
||||||
|
}) })] }), _jsx(UserManagementModal, { isOpen: isAdminModalOpen, onClose: () => setIsAdminModalOpen(false) }), _jsx(CreateTourModal, { isOpen: isCreateModalOpen, onClose: () => setIsCreateModalOpen(false), onSuccess: (tour) => {
|
||||||
|
fetchTour(tour.id);
|
||||||
|
onViewTour(tour.id);
|
||||||
|
} })] }));
|
||||||
};
|
};
|
||||||
//# sourceMappingURL=ExploreMap.js.map
|
//# 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
+103
-7
@@ -1,7 +1,9 @@
|
|||||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||||
|
import { useState } from 'react';
|
||||||
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
import { 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';
|
||||||
|
import { ConfirmModal } from './ConfirmModal.js';
|
||||||
const TimeVariance = ({ planned, actual }) => {
|
const TimeVariance = ({ planned, actual }) => {
|
||||||
if (!actual)
|
if (!actual)
|
||||||
return null;
|
return null;
|
||||||
@@ -9,11 +11,105 @@ 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 [confirmState, setConfirmState] = useState({ open: false });
|
||||||
|
const toggleComplete = async (locationId) => {
|
||||||
|
console.log("Toggle status for location:", locationId);
|
||||||
};
|
};
|
||||||
return (_jsxs("div", { className: "max-w-2xl mx-auto p-4 sm:p-6 bg-gray-50 min-h-screen", children: [_jsx("h2", { className: "text-2xl font-bold text-gray-800 mb-8 px-2", children: "L\u1ED9 tr\u00ECnh chuy\u1EBFn \u0111i" }), _jsx("div", { className: "space-y-8", children: legs.map((leg, legIdx) => (_jsxs("div", { className: "relative", children: [_jsxs("div", { className: "flex items-center mb-4 px-2", children: [_jsxs("div", { className: "bg-blue-600 text-white text-sm font-bold px-3 py-1 rounded-full shadow-sm", children: ["Ch\u1EB7ng ", leg.sequenceNumber] }), _jsx("div", { className: "ml-4 h-[1px] flex-1 bg-gray-200" })] }), _jsx("div", { className: "absolute left-6 top-10 bottom-0 w-0.5 bg-gray-200 -z-0" }), _jsx("div", { className: "space-y-6 ml-2", children: leg.places.map((place) => (_jsxs("div", { className: "relative flex group", children: [_jsx("div", { className: "z-10 mt-1.5 mr-4", children: _jsx("button", { onClick: () => toggleComplete(place.id), className: `transition-colors duration-200 ${place.isCompleted ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`, children: place.isCompleted ? (_jsx(CheckCircle2, { className: "w-8 h-8 bg-white rounded-full" })) : (_jsx(Circle, { className: "w-8 h-8 bg-white rounded-full fill-white" })) }) }), _jsxs("div", { className: `flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${place.isCompleted ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'}`, children: [_jsxs("div", { className: "flex justify-between items-start", children: [_jsxs("div", { children: [_jsx("h3", { className: `font-semibold text-lg ${place.isCompleted ? 'text-gray-500 line-through' : 'text-gray-800'}`, children: place.name }), _jsxs("div", { className: "flex items-center text-sm text-gray-500 mt-1", children: [_jsx(MapPin, { className: "w-3 h-3 mr-1" }), _jsx("span", { className: "truncate max-w-[200px] sm:max-w-md", children: place.address })] })] }), _jsxs("div", { className: "text-right flex flex-col items-end", children: [_jsxs("div", { className: "flex items-center text-sm font-medium text-blue-600", children: [_jsx(Clock, { className: "w-3 h-3 mr-1" }), format(parseISO(place.arrivalTime), 'HH:mm')] }), place.isCompleted && place.completedAt && (_jsxs("div", { className: "text-[10px] text-gray-400 mt-1 italic", children: ["Th\u1EF1c t\u1EBF: ", format(parseISO(place.completedAt), 'HH:mm')] }))] })] }), _jsx(TimeVariance, { planned: place.arrivalTime, actual: place.completedAt })] })] }, place.id))) }), leg.notes && (_jsxs("p", { className: "ml-14 mt-4 text-sm text-gray-400 italic", children: ["* ", leg.notes] }))] }, leg.id))) })] }));
|
const handleAddLeg = async () => {
|
||||||
|
const note = window.prompt("Nhập ghi chú cho chặng mới:", `Chặng ${legs.length + 1}`);
|
||||||
|
if (note && currentTour) {
|
||||||
|
await addLeg(currentTour.id, { note });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleDeclareLegs = async () => {
|
||||||
|
const countStr = window.prompt("Chuyến đi này bạn muốn chia làm bao nhiêu chặng?", "3");
|
||||||
|
const count = parseInt(countStr || "0");
|
||||||
|
if (count > 0 && currentTour) {
|
||||||
|
await initializeLegs(currentTour.id, count);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleEditLeg = async (leg) => {
|
||||||
|
const note = window.prompt("Sửa ghi chú chặng:", leg.note || "");
|
||||||
|
if (note !== null) {
|
||||||
|
await updateLeg(leg.id, { note });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleDeleteLeg = async (legId) => {
|
||||||
|
setConfirmState({
|
||||||
|
open: true,
|
||||||
|
title: 'Xóa chặng',
|
||||||
|
message: 'Bạn có chắc chắn muốn xóa chặng này?',
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await deleteLeg(legId);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setConfirmState({ open: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleDeleteLocation = async (id) => {
|
||||||
|
setConfirmState({
|
||||||
|
open: true,
|
||||||
|
title: 'Xóa địa điểm',
|
||||||
|
message: 'Bạn có chắc chắn muốn xóa địa điểm này?',
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await deleteLocation(id);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setConfirmState({ open: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (_jsxs("div", { className: "max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen", children: [_jsxs("div", { className: "px-2 pt-4", children: [legs.length === 0 ? (_jsxs("div", { className: "text-center py-20 bg-white rounded-3xl border-2 border-dashed border-gray-200", children: [_jsx(List, { className: "w-12 h-12 text-gray-300 mx-auto mb-4" }), _jsx("p", { className: "text-gray-500 font-medium", children: "Ch\u01B0a c\u00F3 ch\u1EB7ng n\u00E0o trong l\u1ED9 tr\u00ECnh." })] })) : (legs.map((leg, legIdx) => {
|
||||||
|
const totalDwellMinutes = leg.locations.reduce((acc, loc) => {
|
||||||
|
if (loc.plannedStart && loc.plannedEnd) {
|
||||||
|
return acc + differenceInMinutes(parseISO(loc.plannedEnd), parseISO(loc.plannedStart));
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, 0);
|
||||||
|
const prevLegLastLoc = legIdx > 0 ? legs[legIdx - 1]?.locations.slice(-1)[0] : null;
|
||||||
|
return (_jsxs("div", { className: "relative mb-12 animate-in fade-in slide-in-from-bottom-4 duration-300", children: [_jsxs("div", { className: "sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2", children: [_jsxs("div", { className: "font-black text-blue-600 text-xl truncate flex-1 flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm", children: leg.sequence }), leg.note || `Chi tiết Chặng ${leg.sequence}`] }), prevLegLastLoc && (_jsxs("div", { className: "flex items-center gap-1 text-[10px] text-gray-400 uppercase tracking-widest ml-10", children: [_jsx(Navigation, { className: "w-2.5 h-2.5 rotate-90" }), " Ti\u1EBFp n\u1ED1i t\u1EEB ", prevLegLastLoc.name] }))] }), _jsxs("div", { className: "flex items-center gap-2 ml-4", children: [['OWNER', 'MANAGER'].includes(userRole || '') && (_jsxs(_Fragment, { children: [_jsx("button", { onClick: () => onAddLocation?.(leg.id), className: "p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all", title: "Th\u00EAm \u0111\u1ECBa \u0111i\u1EC3m v\u00E0o ch\u1EB7ng n\u00E0y", children: _jsx(Plus, { className: "w-4 h-4" }) }), _jsx("button", { onClick: () => handleEditLeg(leg), className: "p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all", children: _jsx(Edit2, { className: "w-4 h-4" }) }), _jsx("button", { onClick: () => handleDeleteLeg(leg.id), className: "p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all", children: _jsx(Trash2, { className: "w-4 h-4" }) })] })), leg.totalDistance !== undefined && (_jsxs("div", { className: "hidden sm:flex items-center gap-2", children: [_jsxs("div", { className: "text-xs font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded-lg border border-blue-100", children: [leg.totalDistance, " km"] }), _jsxs("div", { className: "text-xs font-bold text-gray-500 bg-gray-50 px-2 py-1 rounded-lg border border-gray-100 flex items-center gap-1", children: [_jsx(Clock, { className: "w-3 h-3" }), "~ ", formatTravelTime(Math.round((leg.totalDistance / 35) * 60))] })] })), totalDwellMinutes > 0 && (_jsxs("div", { className: "hidden md:flex text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 items-center gap-1", children: [_jsx(Clock, { className: "w-3 h-3" }), "D\u1EEBng: ", formatTravelTime(totalDwellMinutes)] }))] }), ['OWNER', 'MANAGER'].includes(userRole || '') && legs.length > 0 && (_jsxs("button", { onClick: () => optimizeRouting(leg.id), className: "ml-auto flex items-center gap-1.5 text-xs font-bold text-indigo-600 hover:text-indigo-700 bg-indigo-50 hover:bg-indigo-100 px-3 py-1.5 rounded-xl transition-all", children: [_jsx(Zap, { className: "w-3 h-3" }), "T\u1ED1i \u01B0u"] }))] }), _jsx("div", { className: `absolute left-6 top-16 ${legIdx === legs.length - 1 ? 'bottom-0' : 'bottom-[-3.5rem]'} w-0.5 bg-blue-100 -z-0` }), _jsx("div", { className: "ml-2", children: leg.locations.map((location, idx) => {
|
||||||
|
const nextLocation = leg.locations[idx + 1] || legs[legIdx + 1]?.locations[0];
|
||||||
|
const distanceToNext = nextLocation
|
||||||
|
? calculateDistance(location.latitude, location.longitude, nextLocation.latitude, nextLocation.longitude)
|
||||||
|
: null;
|
||||||
|
const averageSpeed = 35;
|
||||||
|
const travelTimeMinutes = distanceToNext ? Math.round((distanceToNext / averageSpeed) * 60) : null;
|
||||||
|
const dwellMinutes = (location.plannedStart && location.plannedEnd)
|
||||||
|
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
||||||
|
: null;
|
||||||
|
const locationExpense = leg.expenses?.find((e) => e.locationId === location.id);
|
||||||
|
const isStartPoint = legs[0]?.id === leg.id && idx === 0;
|
||||||
|
const isEndPoint = legs[legs.length - 1]?.id === leg.id && idx === leg.locations.length - 1;
|
||||||
|
return (_jsxs("div", { children: [_jsxs("div", { className: "relative flex group mb-6", children: [_jsx("div", { className: "z-10 mt-1.5 mr-4", children: _jsx("button", { onClick: () => toggleComplete(location.id), className: `transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`, children: location.status === 'COMPLETED' ? (_jsx(CheckCircle2, { className: "w-8 h-8 bg-white rounded-full" })) : (_jsx(Circle, { className: "w-8 h-8 bg-white rounded-full fill-white" })) }) }), _jsxs("div", { className: `flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${location.status === 'COMPLETED' ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'}`, children: [_jsxs("div", { className: "flex justify-between items-start", children: [_jsxs("div", { children: [isStartPoint && (_jsx("span", { className: "inline-block px-2 py-0.5 bg-green-100 text-green-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider", children: "\u0110i\u1EC3m b\u1EAFt \u0111\u1EA7u" })), isEndPoint && (_jsx("span", { className: "inline-block px-2 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider", children: "\u0110i\u1EC3m k\u1EBFt th\u00FAc" })), _jsx("h3", { className: `font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`, children: location.name }), _jsxs("div", { className: "flex items-center text-sm text-gray-500 mt-1", children: [_jsx(MapPin, { className: "w-3 h-3 mr-1" }), _jsx("span", { className: "truncate max-w-[200px] sm:max-w-md", children: location.address })] }), location.note && (_jsx("div", { className: "mt-2 text-xs text-gray-600 bg-gray-50 p-2 rounded-lg border border-gray-100 italic", children: location.note })), dwellMinutes !== null && (_jsxs("div", { className: "flex items-center text-xs text-amber-600 font-medium mt-1", children: [_jsx(Clock, { className: "w-3 h-3 mr-1" }), _jsxs("span", { children: ["Th\u1EDDi gian d\u1EEBng: ", formatTravelTime(dwellMinutes)] })] })), locationExpense && (_jsxs("div", { className: "mt-2 text-xs text-indigo-600 bg-indigo-50/80 p-2 rounded-lg border border-indigo-100 space-y-1", children: [_jsxs("div", { className: "flex items-center gap-1 font-bold", children: [_jsx(Zap, { className: "w-3 h-3" }), _jsxs("span", { children: ["Chi ph\u00ED: ", Number(locationExpense.amount).toLocaleString(), "\u0111 (", locationExpense.category, ")"] })] }), locationExpense.description && (_jsxs("div", { className: "text-[10px] text-gray-600", children: ["D\u1ECBch v\u1EE5: ", locationExpense.description] })), locationExpense.note && (_jsx("div", { className: "text-[10px] text-gray-500 italic", children: locationExpense.note })), locationExpense.paidBy && (_jsxs("div", { className: "text-[10px] font-semibold text-indigo-700", children: ["\u0110\u00E3 thanh to\u00E1n: ", locationExpense.paidBy.name] }))] }))] }), _jsxs("div", { className: "text-right flex flex-col items-end", children: [_jsxs("div", { className: "flex items-center text-sm font-medium text-blue-600", children: [_jsx(Clock, { className: "w-3 h-3 mr-1" }), location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'] }), location.status === 'COMPLETED' && location.actualStart && (_jsxs("div", { className: "text-[10px] text-gray-400 mt-1 italic", children: ["Th\u1EF1c t\u1EBF: ", format(parseISO(location.actualStart), 'HH:mm')] })), ['OWNER', 'MANAGER'].includes(userRole || '') && !isStartPoint && !isEndPoint && (_jsxs("div", { className: "flex gap-1 mt-2", children: [_jsx("button", { onClick: () => onEditLocation?.(location), className: "p-1 text-gray-400 hover:text-blue-600 transition-colors", children: _jsx(Edit2, { className: "w-3.5 h-3.5" }) }), _jsx("button", { onClick: () => handleDeleteLocation(location.id), className: "p-1 text-gray-400 hover:text-red-600 transition-colors", children: _jsx(Trash2, { className: "w-3.5 h-3.5" }) })] }))] })] }), _jsx(TimeVariance, { planned: location.plannedStart || '', actual: location.actualStart || null })] })] }), distanceToNext !== null && travelTimeMinutes !== null && (_jsxs("div", { className: "ml-4 -mt-4 mb-2 flex items-center gap-3", children: [_jsx("div", { className: "w-8 flex justify-center", children: _jsx(Navigation, { className: "w-3 h-3 text-blue-400 rotate-180" }) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("span", { className: "text-[10px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100", children: [distanceToNext.toFixed(2), " km"] }), _jsxs("span", { className: "text-[10px] font-bold text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full border border-gray-100 flex items-center gap-1", children: [_jsx(Clock, { className: "w-2.5 h-2.5" }), "~ ", formatTravelTime(travelTimeMinutes)] })] })] }))] }, location.id));
|
||||||
|
}) })] }, leg.id));
|
||||||
|
})), ['OWNER', 'MANAGER'].includes(userRole || '') && (_jsxs("div", { className: "flex flex-col gap-3 pb-20 mt-8", children: [_jsxs("button", { onClick: handleDeclareLegs, className: "w-full py-4 rounded-2xl bg-white text-blue-600 border-2 border-dashed border-blue-200 hover:bg-blue-50 transition-all flex items-center justify-center gap-2 text-sm font-bold", children: [_jsx(List, { className: "w-5 h-5" }), legs.length > 0 ? "Khai báo lại số lượng chặng" : "Bắt đầu bằng việc khai báo số chặng"] }), _jsxs("button", { onClick: handleAddLeg, className: "w-full py-4 rounded-2xl bg-blue-600 text-white shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all flex items-center justify-center gap-2 text-sm font-bold", children: [_jsx(Plus, { className: "w-5 h-5" }), " Th\u00EAm ch\u1EB7ng l\u1EBB v\u00E0o cu\u1ED1i"] })] }))] }), _jsx(ConfirmModal, { isOpen: confirmState.open, title: confirmState.title, message: confirmState.message, onConfirm: () => confirmState.onConfirm?.(), onCancel: () => setConfirmState({ open: false }) })] }));
|
||||||
};
|
};
|
||||||
//# sourceMappingURL=ItineraryTimeline.js.map
|
//# 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
+377
-22
@@ -1,33 +1,388 @@
|
|||||||
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 { ConfirmModal } from './ConfirmModal.js';
|
||||||
const { currentTour, fetchTour, userRole } = useTourStore();
|
import { NotificationModal, useNotificationModal } from './components/NotificationModal.js';
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline } from 'react-leaflet';
|
||||||
|
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||||
|
const MarkerClusterGroup = _MarkerClusterGroup.default || _MarkerClusterGroup;
|
||||||
|
import { useMap } from 'react-leaflet';
|
||||||
|
import { Map as MapIcon, Wallet, Image as ImageIcon, Calendar, Users, ChevronLeft, Settings, Quote, Plus, List, Map as MapIconLucide, MapPin, Flag, Clock, Check, X } from 'lucide-react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||||
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||||
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||||
|
});
|
||||||
|
const START_ICON = L.divIcon({
|
||||||
|
className: 'custom-marker-s',
|
||||||
|
html: `<div class="w-6 h-6 bg-blue-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-[10px] font-black text-white">S</div>`,
|
||||||
|
iconSize: [24, 24],
|
||||||
|
iconAnchor: [12, 12]
|
||||||
|
});
|
||||||
|
const END_ICON = L.divIcon({
|
||||||
|
className: 'custom-marker-e',
|
||||||
|
html: `<div class="w-6 h-6 bg-green-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-[10px] font-black text-white">E</div>`,
|
||||||
|
iconSize: [24, 24],
|
||||||
|
iconAnchor: [12, 12]
|
||||||
|
});
|
||||||
|
const VISIT_ICON = L.divIcon({
|
||||||
|
className: 'custom-marker-v',
|
||||||
|
html: `<div class="w-4 h-4 bg-indigo-500 rounded-full border-2 border-white shadow-md"></div>`,
|
||||||
|
iconSize: [16, 16],
|
||||||
|
iconAnchor: [8, 8]
|
||||||
|
});
|
||||||
|
const MapTourBounds = ({ locations }) => {
|
||||||
|
const map = useMap();
|
||||||
|
const locKey = useMemo(() => JSON.stringify(locations.map(l => [l.latitude, l.longitude])), [locations]);
|
||||||
useEffect(() => {
|
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 },
|
else {
|
||||||
].filter(t => t.visible);
|
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
|
||||||
useEffect(() => {
|
}
|
||||||
if (userRole === 'MEMBER_PHOTO_ONLY') {
|
|
||||||
setActiveTab('photo');
|
|
||||||
}
|
}
|
||||||
}, [userRole]);
|
}, [locKey, map]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const MapContextMenu = ({ onAction }) => {
|
||||||
|
const [menuPos, setMenuPos] = useState(null);
|
||||||
|
const menuRef = React.useRef(null);
|
||||||
|
const legs = useTourStore(state => state.legs);
|
||||||
|
const setMapCenter = useTourStore(state => state.setMapCenter);
|
||||||
|
useMapEvents({
|
||||||
|
contextmenu: (e) => {
|
||||||
|
if (e.originalEvent) {
|
||||||
|
L.DomEvent.preventDefault(e.originalEvent);
|
||||||
|
L.DomEvent.stopPropagation(e.originalEvent);
|
||||||
|
}
|
||||||
|
console.log(`[MAP] Context menu triggered at: ${e.latlng.lat}, ${e.latlng.lng}`);
|
||||||
|
setMenuPos({ x: e.containerPoint.x, y: e.containerPoint.y, latlng: e.latlng });
|
||||||
|
},
|
||||||
|
moveend: (e) => {
|
||||||
|
const map = e.target;
|
||||||
|
const center = map.getCenter();
|
||||||
|
const zoom = map.getZoom();
|
||||||
|
const coords = [center.lat, center.lng];
|
||||||
|
const currentStored = useTourStore.getState().mapCenter;
|
||||||
|
const diff = Math.abs(currentStored[0] - coords[0]) + Math.abs(currentStored[1] - coords[1]);
|
||||||
|
if (diff > 0.0001) {
|
||||||
|
setMapCenter(coords);
|
||||||
|
}
|
||||||
|
localStorage.setItem('map_view_state', JSON.stringify({ center: coords, zoom }));
|
||||||
|
},
|
||||||
|
click: () => setMenuPos(null),
|
||||||
|
dragstart: () => setMenuPos(null),
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (menuPos && menuRef.current) {
|
||||||
|
L.DomEvent.disableClickPropagation(menuRef.current);
|
||||||
|
L.DomEvent.disableScrollPropagation(menuRef.current);
|
||||||
|
}
|
||||||
|
}, [menuPos]);
|
||||||
|
if (!menuPos)
|
||||||
|
return null;
|
||||||
|
return (_jsxs("div", { ref: menuRef, className: "absolute z-[2000] bg-white rounded-2xl shadow-2xl border border-gray-100 py-2 w-48 animate-in zoom-in-95 duration-200", style: { top: menuPos.y, left: menuPos.x }, onClick: (e) => e.stopPropagation(), onContextMenu: (e) => e.preventDefault(), children: [_jsxs("button", { onClick: () => { onAction('START', menuPos.latlng); setMenuPos(null); }, className: "w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-gray-700 flex items-center gap-2", children: [_jsx("div", { className: "w-2 h-2 rounded-full bg-blue-600" }), " B\u1EAFt \u0111\u1EA7u t\u1EEB \u0111\u00E2y"] }), _jsxs("button", { onClick: () => { onAction('END', menuPos.latlng); setMenuPos(null); }, className: "w-full text-left px-4 py-2 hover:bg-green-50 text-sm font-bold text-gray-700 flex items-center gap-2 border-b border-gray-50", children: [_jsx("div", { className: "w-2 h-2 rounded-full bg-green-600" }), " K\u1EBFt th\u00FAc \u1EDF \u0111\u00E2y"] }), _jsx("div", { className: "px-4 py-2 text-[10px] font-black text-gray-400 uppercase tracking-widest", children: "Th\u00EAm v\u00E0o ch\u1EB7ng" }), legs.map(leg => (_jsxs("button", { onClick: () => { onAction(`ADD_TO_LEG_${leg.id}`, menuPos.latlng); setMenuPos(null); }, className: "w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-medium text-gray-600 truncate", children: ["Ch\u1EB7ng ", leg.sequence, ": ", leg.note || 'Không có ghi chú'] }, leg.id)))] }));
|
||||||
|
};
|
||||||
|
export const TourDetailPage = ({ onBack }) => {
|
||||||
|
const [activeTab, setActiveTab] = useState('plan');
|
||||||
|
const [viewMode, setViewMode] = useState('timeline');
|
||||||
|
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
|
||||||
|
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
|
||||||
|
const [targetLegId, setTargetLegId] = useState(null);
|
||||||
|
const [editingLocation, setEditingLocation] = useState(null);
|
||||||
|
const [selectedMember, setSelectedMember] = useState(null);
|
||||||
|
const [isMemberDetailOpen, setIsMemberDetailOpen] = useState(false);
|
||||||
|
const [joinRequests, setJoinRequests] = useState([]);
|
||||||
|
const [joinRequestActionId, setJoinRequestActionId] = useState(null);
|
||||||
|
const [confirmState, setConfirmState] = useState({ open: false });
|
||||||
|
const { currentTour, legs, fetchTour, fetchPublicTours, publicTours, userRole, mapCenter, setMapCenter, updateTourStartPoint, updateTourEndPoint, initializeLegs, addLocation, addMember, removeMember, fetchJoinRequests, acceptJoinRequest, rejectJoinRequest } = useTourStore();
|
||||||
|
const notificationModal = useNotificationModal();
|
||||||
|
const [initialViewState] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('map_view_state');
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(saved);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const canEdit = ['OWNER', 'MANAGER'].includes(userRole || '');
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentTour && ['OWNER', 'MANAGER'].includes(userRole || '')) {
|
||||||
|
fetchJoinRequests(currentTour.id).then(setJoinRequests).catch(() => setJoinRequests([]));
|
||||||
|
}
|
||||||
|
}, [currentTour, userRole]);
|
||||||
|
const [mapZoom] = useState(initialViewState?.zoom || 13);
|
||||||
|
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialViewState) {
|
||||||
|
setMapCenter(initialViewState.center);
|
||||||
|
}
|
||||||
|
const loadData = async () => {
|
||||||
|
if (publicTours.length === 0) {
|
||||||
|
await fetchPublicTours();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (publicTours.length > 0 && !currentTour) {
|
||||||
|
fetchTour(publicTours[0].id);
|
||||||
|
}
|
||||||
|
}, [publicTours, currentTour, fetchTour]);
|
||||||
|
const handleDeclareLegs = async () => {
|
||||||
|
const countStr = window.prompt("Chuyến đi này bạn muốn chia làm bao nhiêu chặng?", "3");
|
||||||
|
const count = parseInt(countStr || "0");
|
||||||
|
if (count > 0 && currentTour) {
|
||||||
|
await initializeLegs(currentTour.id, count);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleMapAction = async (action, latlng) => {
|
||||||
|
console.log(`[FRONTEND] Triggered ${action} at:`, { lat: latlng.lat, lng: latlng.lng });
|
||||||
|
const currentLegs = useTourStore.getState().legs;
|
||||||
|
if (!currentTour || currentLegs.length === 0) {
|
||||||
|
alert("Tour chưa có chặng nào. Vui lòng tạo chặng (Leg) trước khi thực hiện.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let targetLegId = currentLegs[0].id;
|
||||||
|
let defaultName = "Địa điểm mới";
|
||||||
|
let locationType = 'VISIT';
|
||||||
|
if (action === 'START') {
|
||||||
|
defaultName = "Điểm bắt đầu";
|
||||||
|
locationType = 'MOVE';
|
||||||
|
}
|
||||||
|
if (action === 'END') {
|
||||||
|
targetLegId = currentLegs[currentLegs.length - 1].id;
|
||||||
|
defaultName = "Điểm kết thúc";
|
||||||
|
locationType = 'MOVE';
|
||||||
|
}
|
||||||
|
if (action.startsWith('ADD_TO_LEG_')) {
|
||||||
|
targetLegId = action.replace('ADD_TO_LEG_', '');
|
||||||
|
}
|
||||||
|
let detectedName = "";
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latlng.lat}&lon=${latlng.lng}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const addr = data.address;
|
||||||
|
detectedName = addr.amenity || addr.building || addr.historic || addr.tourist ||
|
||||||
|
addr.shop || addr.office || addr.leisure || addr.attraction ||
|
||||||
|
addr.road || addr.neighbourhood || addr.suburb ||
|
||||||
|
data.display_name?.split(',')[0] || "";
|
||||||
|
console.log(`[FRONTEND] Geocoding Result: "${detectedName}"`);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.warn("[FRONTEND] Reverse Geocoding failed:", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (action === 'START') {
|
||||||
|
const resolvedPlaceName = detectedName || "Điểm xuất phát";
|
||||||
|
console.log(`[FRONTEND] Updating START point to: ${resolvedPlaceName} at`, latlng);
|
||||||
|
await updateTourStartPoint(currentTour.id, {
|
||||||
|
name: resolvedPlaceName,
|
||||||
|
latitude: latlng.lat,
|
||||||
|
longitude: latlng.lng,
|
||||||
|
});
|
||||||
|
console.log("[FRONTEND] START point updated successfully.");
|
||||||
|
}
|
||||||
|
else if (action === 'END') {
|
||||||
|
const finalName = detectedName || "Điểm kết thúc";
|
||||||
|
await updateTourEndPoint(currentTour.id, {
|
||||||
|
name: finalName,
|
||||||
|
latitude: latlng.lat,
|
||||||
|
longitude: latlng.lng,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const name = window.prompt("Xác nhận tên địa điểm tham quan:", detectedName || defaultName);
|
||||||
|
if (!name)
|
||||||
|
return;
|
||||||
|
await addLocation(currentTour.id, {
|
||||||
|
name,
|
||||||
|
address: '',
|
||||||
|
latitude: latlng.lat,
|
||||||
|
longitude: latlng.lng,
|
||||||
|
legId: targetLegId,
|
||||||
|
type: locationType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
if (error.message.includes('Không tìm thấy chặng')) {
|
||||||
|
alert("Lỗi: Bạn cần tạo ít nhất một Chặng (Leg) trước khi xác định điểm Bắt đầu/Kết thúc.");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
alert("Đã xảy ra lỗi: " + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const startPoint = legs[0]?.locations[0];
|
||||||
|
const lastLeg = legs[legs.length - 1];
|
||||||
|
const endPoint = lastLeg?.locations[lastLeg.locations.length - 1];
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: true },
|
||||||
|
{ id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '') },
|
||||||
|
{ id: 'photo', label: 'Ảnh', icon: ImageIcon, visible: true },
|
||||||
|
{ id: 'settings', label: 'Cài đặt', icon: Settings, visible: userRole === 'OWNER' },
|
||||||
|
].filter(t => t.visible);
|
||||||
|
const hasFinanceAccess = ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
||||||
|
const travelQuotes = [
|
||||||
|
"Đừng nghe họ nói, hãy tự mình đi xem.",
|
||||||
|
"Thế giới là một cuốn sách, và ai không đi du lịch thì chỉ mới đọc được một trang.",
|
||||||
|
"Hành trình ngàn dặm bắt đầu từ một bước chân.",
|
||||||
|
"Đi là để trở về, nhưng với một tâm hồn mới."
|
||||||
|
];
|
||||||
|
const randomQuote = useMemo(() => travelQuotes[Math.floor(Math.random() * travelQuotes.length)], []);
|
||||||
const tourInfo = {
|
const tourInfo = {
|
||||||
title: currentTour?.title || "Hành trình khám phá TP.HCM",
|
title: currentTour?.title || "Hành trình khám phá TP.HCM",
|
||||||
date: "20 - 21 Tháng 11, 2023",
|
date: currentTour?.startDate ? `${new Date(currentTour.startDate).toLocaleDateString('vi-VN')} - ${new Date(currentTour.endDate).toLocaleDateString('vi-VN')}` : "Chưa xác định ngày",
|
||||||
members: 5,
|
membersCount: currentTour?.participants?.length || 0,
|
||||||
budget: "2.500.000 VND"
|
budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND",
|
||||||
|
coverImage: currentTour?.photos?.[0]?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
|
||||||
};
|
};
|
||||||
return (_jsxs("div", { className: "min-h-screen bg-white", children: [_jsxs("div", { className: "sticky top-0 z-30 bg-white border-b border-gray-100 px-4 py-3 flex items-center justify-between", children: [_jsx("button", { className: "p-2 hover:bg-gray-100 rounded-full transition-colors", children: _jsx(ChevronLeft, { className: "w-6 h-6 text-gray-600" }) }), _jsx("h1", { className: "text-lg font-bold text-gray-800 truncate px-4", children: tourInfo.title }), _jsx("div", { className: "w-10" }), " "] }), _jsx("div", { className: "bg-blue-600 text-white p-6 pb-20", children: _jsxs("div", { className: "max-w-2xl mx-auto space-y-4", children: [_jsxs("div", { className: "flex flex-wrap gap-4 text-sm opacity-90", children: [_jsxs("div", { className: "flex items-center", children: [_jsx(Calendar, { className: "w-4 h-4 mr-1.5" }), tourInfo.date] }), _jsxs("div", { className: "flex items-center", children: [_jsx(Users, { className: "w-4 h-4 mr-1.5" }), tourInfo.members, " th\u00E0nh vi\u00EAn"] })] }), _jsxs("div", { className: "flex items-baseline gap-2", children: [_jsx("span", { className: "text-3xl font-bold", children: tourInfo.budget }), _jsx("span", { className: "text-blue-100 text-sm", children: "d\u1EF1 ki\u1EBFn" })] })] }) }), _jsxs("div", { className: "max-w-2xl mx-auto -mt-12 px-4 pb-24", children: [_jsx("div", { className: "bg-white rounded-2xl shadow-xl border border-gray-100 p-1 flex mb-6", children: tabs.map((tab) => (_jsxs("button", { onClick: () => setActiveTab(tab.id), className: `flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${activeTab === tab.id
|
return (_jsxs("div", { className: "min-h-screen bg-gray-50 pb-20", children: [_jsxs("div", { className: "sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-gray-100 px-4 py-3 flex items-center justify-between", children: [_jsx("button", { onClick: onBack, className: "p-2 hover:bg-gray-100 rounded-full transition-colors", children: _jsx(ChevronLeft, { className: "w-6 h-6 text-gray-600" }) }), _jsx("h1", { className: "text-lg font-bold text-gray-800 truncate px-4", children: tourInfo.title }), _jsx("div", { className: "w-10" }), " "] }), _jsxs("div", { className: "relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end", children: [_jsx("img", { src: tourInfo.coverImage, className: "absolute inset-0 w-full h-full object-cover opacity-60", alt: "Tour Cover" }), _jsx("div", { className: "absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" }), _jsx("div", { className: "relative z-10 p-6 text-white pt-28 pb-20", children: _jsxs("div", { className: "max-w-2xl mx-auto space-y-4", children: [_jsx("h2", { className: "text-3xl font-black tracking-tight drop-shadow-md", children: tourInfo.title }), _jsxs("div", { className: "mt-3 text-[11px] sm:text-sm font-bold text-white bg-black/40 backdrop-blur-md px-4 py-2 rounded-xl border border-white/10 inline-flex items-center max-w-full", children: [_jsx("span", { className: "text-white/60 mr-1", children: "L\u1ED9 tr\u00ECnh:" }), _jsx("span", { className: "text-blue-300", children: "\u0110i\u1EC3m xu\u1EA5t ph\u00E1t:" }), _jsx("span", { className: "ml-1 text-white banner-location-text", title: startPoint?.name, children: startPoint?.name || '...' }), _jsx("span", { className: "mx-2 text-white/30", children: "-" }), _jsx("span", { className: "text-green-300", children: "\u0110i\u1EC3m k\u1EBFt th\u00FAc:" }), _jsx("span", { className: "ml-1 text-white banner-location-text", title: endPoint?.name, children: endPoint?.name || '...' })] }), _jsxs("div", { className: "flex flex-wrap gap-4 text-sm font-medium opacity-90", children: [_jsxs("div", { className: "flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10", children: [_jsx(Calendar, { className: "w-4 h-4 mr-1.5" }), tourInfo.date] }), _jsxs("div", { className: "flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10", children: [_jsx(Users, { className: "w-4 h-4 mr-1.5" }), tourInfo.membersCount, " th\u00E0nh vi\u00EAn"] })] }), _jsxs("div", { className: "flex items-center gap-2 mt-4", children: [_jsxs("div", { className: "flex flex-wrap gap-2", children: [currentTour?.participants?.slice(0, 5).map((p, i) => (_jsx("button", { onClick: () => {
|
||||||
|
setSelectedMember(p);
|
||||||
|
setIsMemberDetailOpen(true);
|
||||||
|
}, className: "w-10 h-10 rounded-full border-2 border-white bg-blue-100 flex items-center justify-center text-blue-600 font-bold overflow-hidden shadow-lg hover:scale-110 transition-transform", title: p.user?.name || p.userId, children: _jsx("img", { src: `https://i.pravatar.cc/100?u=${p.userId}`, alt: "Avatar" }) }, p.userId || i))), joinRequests.slice(0, 3).map((req) => (_jsxs("div", { className: "relative group", children: [_jsx("div", { className: "w-10 h-10 rounded-full border-2 border-amber-400 bg-amber-50 flex items-center justify-center text-amber-700 font-bold overflow-hidden shadow-lg", children: req.user?.name?.charAt(0) || '?' }), _jsxs("div", { className: "absolute -top-1 -right-1 flex", children: [_jsx("button", { type: "button", disabled: joinRequestActionId === req.id, onClick: async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!currentTour)
|
||||||
|
return;
|
||||||
|
setConfirmState({
|
||||||
|
open: true,
|
||||||
|
title: 'Chấp nhận yêu cầu',
|
||||||
|
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`,
|
||||||
|
onConfirm: async () => {
|
||||||
|
setJoinRequestActionId(req.id);
|
||||||
|
try {
|
||||||
|
await acceptJoinRequest(currentTour.id, req.id);
|
||||||
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
notificationModal.openModal('Thông báo', e.message || 'Không thể chấp nhận yêu cầu', 'error');
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setJoinRequestActionId(null);
|
||||||
|
setConfirmState({ open: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, className: "w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50", "aria-label": "Accept", children: "+" }), _jsx("button", { type: "button", disabled: joinRequestActionId === req.id, onClick: async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!currentTour)
|
||||||
|
return;
|
||||||
|
setConfirmState({
|
||||||
|
open: true,
|
||||||
|
title: 'Từ chối yêu cầu',
|
||||||
|
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`,
|
||||||
|
onConfirm: async () => {
|
||||||
|
setJoinRequestActionId(req.id);
|
||||||
|
try {
|
||||||
|
await rejectJoinRequest(currentTour.id, req.id);
|
||||||
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
notificationModal.openModal('Thông báo', e.message || 'Không thể từ chối yêu cầu', 'error');
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setJoinRequestActionId(null);
|
||||||
|
setConfirmState({ open: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, className: "w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1", "aria-label": "Reject", children: "x" })] })] }, req.id))), tourInfo.membersCount > 5 && (_jsxs("div", { className: "w-10 h-10 rounded-full border-2 border-white bg-gray-800 flex items-center justify-center text-[10px] font-bold text-white shadow-lg", children: ["+", tourInfo.membersCount - 5] }))] }), _jsx("button", { onClick: () => {
|
||||||
|
if (!currentTour)
|
||||||
|
return;
|
||||||
|
if (canEdit)
|
||||||
|
setIsAddMemberOpen(true);
|
||||||
|
}, disabled: !canEdit, className: `p-2 rounded-full border backdrop-blur-sm transition-all ml-2 ${canEdit ? 'bg-white/10 hover:bg-white/20 border-white/20' : 'bg-white/5 border-white/10 opacity-50'}`, children: _jsx(Plus, { className: "w-4 h-4" }) })] })] }) })] }), _jsx("div", { className: "max-w-2xl mx-auto -mt-10 px-4 relative z-10", children: _jsx("div", { className: `rounded-3xl p-6 shadow-2xl transition-all duration-500 ${hasFinanceAccess ? 'bg-indigo-600 text-white shadow-indigo-200' : 'bg-white text-gray-600 border border-gray-100'}`, children: _jsx("div", { className: "flex justify-between items-center", children: hasFinanceAccess ? (_jsxs(_Fragment, { children: [_jsxs("div", { children: [_jsx("p", { className: "text-indigo-100 text-xs font-black uppercase tracking-widest mb-1", children: "T\u1ED5ng chi ti\u00EAu hi\u1EC7n t\u1EA1i" }), _jsx("h3", { className: "text-3xl font-black", children: tourInfo.budget })] }), _jsx("div", { className: "p-4 bg-white/10 rounded-2xl backdrop-blur-md", children: _jsx(Wallet, { className: "w-8 h-8" }) })] })) : (_jsxs("div", { className: "flex items-start gap-4 py-2", children: [_jsx(Quote, { className: "w-8 h-8 text-blue-500 opacity-30 flex-shrink-0" }), _jsxs("p", { className: "italic text-lg font-medium leading-relaxed", children: ["\"", randomQuote, "\""] })] })) }) }) }), _jsxs("div", { className: "max-w-2xl mx-auto mt-4 px-4 grid grid-cols-1 sm:grid-cols-2 gap-3 animate-in fade-in slide-in-from-top-2 duration-500", children: [startPoint && (_jsxs("div", { className: "bg-white p-4 rounded-2xl border border-blue-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow", children: [_jsx("div", { className: "w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center text-blue-600 shadow-inner", children: _jsx(MapPin, { className: "w-5 h-5" }) }), _jsxs("div", { className: "overflow-hidden", children: [_jsx("p", { className: "text-[10px] font-black text-blue-400 uppercase tracking-widest mb-0.5", children: "\u0110i\u1EC3m xu\u1EA5t ph\u00E1t" }), _jsx("p", { className: "text-sm font-bold text-gray-800 truncate", children: startPoint.name })] })] })), endPoint && (_jsxs("div", { className: "bg-white p-4 rounded-2xl border border-green-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow", children: [_jsx("div", { className: "w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center text-green-600 shadow-inner", children: _jsx(Flag, { className: "w-5 h-5" }) }), _jsxs("div", { className: "overflow-hidden", children: [_jsx("p", { className: "text-[10px] font-black text-green-400 uppercase tracking-widest mb-0.5", children: "\u0110i\u1EC3m k\u1EBFt th\u00FAc" }), _jsx("p", { className: "text-sm font-bold text-gray-800 truncate", children: endPoint.name })] })] }))] }), _jsxs("div", { className: `${activeTab === 'plan' && viewMode === 'map' ? 'w-full' : 'max-w-2xl mx-auto'} mt-6 px-4 pb-24`, children: [_jsx("div", { className: "bg-white rounded-2xl shadow-lg border border-gray-100 p-1 flex mb-6 sticky top-20 z-20", children: tabs.map((tab) => (_jsxs("button", { onClick: () => setActiveTab(tab.id), className: `flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${activeTab === tab.id
|
||||||
? 'bg-blue-50 text-blue-600 shadow-sm'
|
? 'bg-blue-50 text-blue-600 shadow-sm'
|
||||||
: 'text-gray-400 hover:text-gray-600'}`, children: [_jsx(tab.icon, { className: `w-4 h-4 mr-2 ${activeTab === tab.id ? 'animate-pulse' : ''}` }), tab.label] }, tab.id))) }), _jsxs("div", { className: "transition-opacity duration-300", children: [activeTab === 'plan' && (_jsx("div", { className: "animate-in fade-in slide-in-from-bottom-4", children: _jsx(ItineraryTimeline, {}) })), activeTab === 'expense' && (_jsx("div", { className: "animate-in fade-in slide-in-from-bottom-4", children: _jsx(ExpenseManager, {}) })), activeTab === 'photo' && (_jsx("div", { className: "grid grid-cols-3 gap-2 animate-in fade-in", children: [1, 2, 3, 4, 5, 6].map((i) => (_jsxs("div", { className: "aspect-square bg-gray-100 rounded-lg overflow-hidden relative group", children: [_jsx("div", { className: "absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" }), _jsx("img", { src: `https://picsum.photos/seed/${i + 10}/400/400`, alt: "Tour photo", className: "w-full h-full object-cover" })] }, i))) }))] })] }), _jsx("div", { className: "fixed bottom-6 left-1/2 -translate-x-1/2 z-40", children: _jsx("button", { className: "bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold", children: activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh' }) })] }));
|
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'}`, children: [_jsx(tab.icon, { className: `w-4 h-4 mr-2 ${activeTab === tab.id ? 'scale-110' : ''}` }), tab.label] }, tab.id))) }), _jsxs("div", { className: "transition-opacity duration-300", children: [activeTab === 'plan' && (_jsxs("div", { className: "animate-in fade-in slide-in-from-bottom-2", children: [_jsx("div", { className: "flex justify-center mb-6", children: _jsxs("div", { className: "bg-gray-100 p-1 rounded-2xl flex gap-1", children: [_jsxs("button", { onClick: () => setViewMode('timeline'), className: `flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'timeline' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`, children: [_jsx(List, { className: "w-3.5 h-3.5" }), " Danh s\u00E1ch"] }), _jsxs("button", { onClick: () => setViewMode('map'), className: `flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'map' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`, children: [_jsx(MapIconLucide, { className: "w-3.5 h-3.5" }), " B\u1EA3n \u0111\u1ED3"] })] }) }), viewMode === 'timeline' ? (_jsx(ItineraryTimeline, { onAddLocation: (legId) => {
|
||||||
|
setTargetLegId(legId);
|
||||||
|
setEditingLocation(null);
|
||||||
|
setIsAddLocationOpen(true);
|
||||||
|
}, onEditLocation: (loc) => {
|
||||||
|
setEditingLocation(loc);
|
||||||
|
setTargetLegId(loc.legId);
|
||||||
|
setMapCenter([loc.latitude, loc.longitude]);
|
||||||
|
setIsAddLocationOpen(true);
|
||||||
|
} })) : (_jsxs("div", { className: "h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative", children: [_jsxs(MapContainer, { center: initialViewState?.center || mapCenter, zoom: mapZoom, className: "h-full w-full", preferCanvas: true, children: [_jsx(TileLayer, { url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" }), canEdit && _jsx(MapContextMenu, { onAction: handleMapAction }), _jsx(MapTourBounds, { locations: allLocations }), allLocations.length > 1 && (_jsx(Polyline, { positions: allLocations.map(l => [l.latitude, l.longitude]), color: "#3b82f6", weight: 3, dashArray: "5, 10", smoothFactor: 1.5 })), _jsx(MarkerClusterGroup, { chunkedLoading: true, children: legs.flatMap(l => l.locations).map((loc) => {
|
||||||
|
const isStart = startPoint?.id === loc.id;
|
||||||
|
const isEnd = endPoint?.id === loc.id;
|
||||||
|
const icon = isStart ? START_ICON : isEnd ? END_ICON : VISIT_ICON;
|
||||||
|
return (_jsx(Marker, { position: [loc.latitude, loc.longitude], icon: icon, children: _jsxs(Popup, { children: [_jsx("div", { className: "font-bold", children: loc.name }), _jsx("div", { className: "text-xs text-gray-500", children: loc.type })] }) }, loc.id));
|
||||||
|
}) })] }), _jsx("div", { className: "absolute top-4 left-4 z-[1000] bg-white/90 backdrop-blur-md p-3 rounded-2xl text-[10px] font-bold text-gray-500 shadow-lg border border-white", children: "M\u1EB9o: Nh\u1EA5n gi\u1EEF (Mobile) ho\u1EB7c Chu\u1ED9t ph\u1EA3i \u0111\u1EC3 ghim \u0111\u1ECBa \u0111i\u1EC3m" })] }))] })), activeTab === 'expense' && (_jsx("div", { className: "animate-in fade-in slide-in-from-bottom-4", children: _jsx(ExpenseManager, {}) })), activeTab === 'photo' && (_jsx("div", { className: "grid grid-cols-3 gap-1.5 animate-in fade-in", children: [1, 2, 3, 4, 5, 6].map((i) => (_jsxs("div", { className: "aspect-square bg-gray-200 rounded-xl overflow-hidden relative group border border-white", children: [_jsx("div", { className: "absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" }), _jsx("img", { src: `https://picsum.photos/seed/${i + 10}/400/400`, alt: "Tour photo", className: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" })] }, i))) })), activeTab === 'settings' && (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "p-6 bg-white rounded-3xl border border-dashed border-gray-200 animate-in zoom-in-95", children: [_jsxs("div", { className: "flex items-center gap-3 mb-4", children: [_jsx(Clock, { className: "w-6 h-6 text-blue-500" }), _jsx("h3", { className: "text-lg font-bold text-gray-900", children: "Y\u00EAu c\u1EA7u tham gia" }), _jsxs("span", { className: "text-xs font-bold text-gray-500 bg-gray-100 px-2 py-1 rounded-full", children: [joinRequests.length, " \u0111ang ch\u1EDD"] })] }), _jsxs("div", { className: "space-y-2", children: [joinRequests.map((req) => (_jsxs("div", { className: "flex items-center justify-between gap-3 p-3 rounded-2xl border border-gray-100 bg-white", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "w-9 h-9 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-700 font-bold text-sm", children: req.user?.name?.charAt(0) || '?' }), _jsxs("div", { children: [_jsx("div", { className: "text-sm font-bold text-gray-800", children: req.user?.name || req.userId }), _jsxs("div", { className: "text-[11px] text-gray-500", children: ["\u0110\u01B0\u1EE3c m\u1EDDi b\u1EDFi ", req.requestedBy?.name, " \u2022 ", new Date(req.createdAt).toLocaleString('vi-VN')] })] })] }), _jsxs("div", { className: "flex gap-2", children: [_jsx("button", { disabled: joinRequestActionId === req.id, onClick: async () => {
|
||||||
|
if (!currentTour)
|
||||||
|
return;
|
||||||
|
setConfirmState({
|
||||||
|
open: true,
|
||||||
|
title: 'Chấp nhận yêu cầu',
|
||||||
|
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`,
|
||||||
|
onConfirm: async () => {
|
||||||
|
setJoinRequestActionId(req.id);
|
||||||
|
try {
|
||||||
|
await acceptJoinRequest(currentTour.id, req.id);
|
||||||
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
notificationModal.openModal('Thông báo', e.message || 'Không thể chấp nhận yêu cầu', 'error');
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setJoinRequestActionId(null);
|
||||||
|
setConfirmState({ open: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, className: "p-2 rounded-xl bg-green-50 text-green-700 hover:bg-green-100 disabled:opacity-50", "aria-label": "Accept", children: _jsx(Check, { className: "w-4 h-4" }) }), _jsx("button", { disabled: joinRequestActionId === req.id, onClick: async () => {
|
||||||
|
if (!currentTour)
|
||||||
|
return;
|
||||||
|
setConfirmState({
|
||||||
|
open: true,
|
||||||
|
title: 'Từ chối yêu cầu',
|
||||||
|
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`,
|
||||||
|
onConfirm: async () => {
|
||||||
|
setJoinRequestActionId(req.id);
|
||||||
|
try {
|
||||||
|
await rejectJoinRequest(currentTour.id, req.id);
|
||||||
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
notificationModal.openModal('Thông báo', e.message || 'Không thể từ chối yêu cầu', 'error');
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setJoinRequestActionId(null);
|
||||||
|
setConfirmState({ open: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, className: "p-2 rounded-xl bg-red-50 text-red-700 hover:bg-red-100 disabled:opacity-50", "aria-label": "Reject", children: _jsx(X, { className: "w-4 h-4" }) })] })] }, req.id))), joinRequests.length === 0 && (_jsx("div", { className: "text-center py-8 text-sm text-gray-500", children: "Kh\u00F4ng c\u00F3 y\u00EAu c\u1EA7u tham gia n\u00E0o \u0111ang ch\u1EDD ph\u00EA duy\u1EC7t." }))] })] }), _jsxs("div", { className: "p-8 text-center bg-gray-50 rounded-3xl border border-dashed border-gray-200", children: [_jsx(Settings, { className: "w-10 h-10 text-gray-300 mx-auto mb-3" }), _jsx("p", { className: "text-gray-500 font-medium", children: "T\u00EDnh n\u0103ng c\u00E0i \u0111\u1EB7t kh\u00E1c \u0111ang \u0111\u01B0\u1EE3c c\u1EADp nh\u1EADt..." })] })] }))] })] }), canEdit && (_jsx("div", { className: "fixed bottom-6 left-1/2 -translate-x-1/2 z-40", children: _jsx("button", { onClick: () => {
|
||||||
|
setTargetLegId(null);
|
||||||
|
setEditingLocation(null);
|
||||||
|
if (activeTab === 'plan')
|
||||||
|
setIsAddLocationOpen(true);
|
||||||
|
}, className: "bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold", children: activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh' }) })), currentTour && (_jsx(AddMemberModal, { isOpen: isAddMemberOpen, onClose: () => setIsAddMemberOpen(false), tourId: currentTour.id, participants: currentTour.participants || [], joinRequests: joinRequests, onRemoveMember: (userId) => removeMember(currentTour.id, userId), onMemberAdded: () => fetchTour(currentTour.id), userRole: userRole || undefined })), currentTour && (_jsx(AddLocationModal, { isOpen: isAddLocationOpen, onClose: () => setIsAddLocationOpen(false), initialLegId: targetLegId || undefined, editingLocation: editingLocation, tourId: currentTour.id })), isMemberDetailOpen && selectedMember && (_jsxs("div", { className: "fixed inset-0 z-[2100] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/60 backdrop-blur-sm", onClick: () => setIsMemberDetailOpen(false) }), _jsxs("div", { className: "relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "w-12 h-12 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold", children: selectedMember.user?.name?.charAt(0) || '?' }), _jsxs("div", { children: [_jsx("div", { className: "text-base font-bold text-gray-900", children: selectedMember.user?.name || 'Chưa đặt tên' }), _jsx("div", { className: "text-xs text-gray-500", children: selectedMember.user?.email }), _jsx("div", { className: "text-[10px] font-semibold text-gray-500", children: selectedMember.role })] })] }), (selectedMember.user?.phone || selectedMember.user?.address) && (_jsxs("div", { className: "mt-3 text-xs text-gray-600 space-y-1", children: [selectedMember.user?.phone && _jsxs("div", { children: ["\uD83D\uDCDE ", selectedMember.user.phone] }), selectedMember.user?.address && _jsxs("div", { children: ["\uD83D\uDCCD ", selectedMember.user.address] })] })), _jsxs("div", { className: "mt-4 flex justify-end gap-2", children: [_jsx("button", { onClick: () => setIsMemberDetailOpen(false), className: "px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100", children: "\u0110\u00F3ng" }), canEdit && selectedMember.role !== 'OWNER' && (_jsx("button", { onClick: async () => {
|
||||||
|
if (!currentTour || !selectedMember)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
await removeMember(currentTour.id, selectedMember.userId);
|
||||||
|
setIsMemberDetailOpen(false);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
notificationModal.openModal('Thông báo', 'Không thể xóa thành viên', 'error');
|
||||||
|
}
|
||||||
|
}, className: "px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold", children: "X\u00F3a" })), canEdit && selectedMember.role === 'OWNER' && (_jsx("button", { onClick: () => {
|
||||||
|
setIsMemberDetailOpen(false);
|
||||||
|
setIsAddMemberOpen(true);
|
||||||
|
}, className: "px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl text-sm font-bold", children: "M\u1EDDi th\u00EAm ng\u01B0\u1EDDi" }))] })] })] })), _jsx(ConfirmModal, { isOpen: confirmState.open, title: confirmState.title, message: confirmState.message, onConfirm: () => confirmState.onConfirm?.(), onCancel: () => setConfirmState({ open: false }) }), _jsx(NotificationModal, { isOpen: notificationModal.modalState?.isOpen ?? false, title: notificationModal.modalState?.title, message: notificationModal.modalState?.message, type: notificationModal.modalState?.type, onConfirm: () => notificationModal.closeModal() })] }));
|
||||||
};
|
};
|
||||||
//# sourceMappingURL=TourDetailPage.js.map
|
//# sourceMappingURL=TourDetailPage.js.map
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+25
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
export interface NotificationModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
type?: 'info' | 'success' | 'warning' | 'error';
|
||||||
|
confirmButtonText?: string;
|
||||||
|
cancelButtonText?: string;
|
||||||
|
}
|
||||||
|
export declare const NotificationModal: React.FC<NotificationModalProps>;
|
||||||
|
export declare const useNotificationModal: () => {
|
||||||
|
modalState: {
|
||||||
|
isOpen: boolean;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
type?: "info" | "success" | "warning" | "error";
|
||||||
|
onConfirm?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
};
|
||||||
|
openModal: (title: string, message: string, type?: "info" | "success" | "warning" | "error", onConfirm?: () => void, onCancel?: () => void) => () => void;
|
||||||
|
closeModal: () => void;
|
||||||
|
};
|
||||||
|
export default NotificationModal;
|
||||||
Vendored
+81
@@ -0,0 +1,81 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useState } from 'react';
|
||||||
|
const Icons = {
|
||||||
|
info: (props) => (_jsxs("svg", { ...props, viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "2" }), _jsx("path", { d: "M12 16v-4", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" }), _jsx("path", { d: "M12 8h.01", stroke: "currentColor", strokeWidth: "3", strokeLinecap: "round" })] })),
|
||||||
|
success: (props) => (_jsxs("svg", { ...props, viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "2" }), _jsx("path", { d: "M8 12l2.5 2.5L15.5 9", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" })] })),
|
||||||
|
warning: (props) => (_jsxs("svg", { ...props, viewBox: "0 0 24 24", fill: "none", children: [_jsx("path", { d: "M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z", stroke: "currentColor", strokeWidth: "2" }), _jsx("line", { x1: "12", y1: "9", x2: "12", y2: "13", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" }), _jsx("circle", { cx: "12", cy: "17", r: "1", fill: "currentColor" })] })),
|
||||||
|
error: (props) => (_jsxs("svg", { ...props, viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "2" }), _jsx("line", { x1: "15", y1: "9", x2: "9", y2: "15", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" }), _jsx("line", { x1: "9", y1: "9", x2: "15", y2: "15", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" })] })),
|
||||||
|
};
|
||||||
|
const defaultProps = {
|
||||||
|
title: 'Thông báo',
|
||||||
|
type: 'info',
|
||||||
|
confirmButtonText: 'OK',
|
||||||
|
cancelButtonText: 'Hủy',
|
||||||
|
};
|
||||||
|
export const NotificationModal = ({ isOpen, title = defaultProps.title, message, onConfirm, onCancel, type = defaultProps.type, confirmButtonText = defaultProps.confirmButtonText, cancelButtonText = defaultProps.cancelButtonText, }) => {
|
||||||
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
|
const getTypeStyles = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'success':
|
||||||
|
return { bg: '#d4edda', border: '#c3e6cb', text: '#155724' };
|
||||||
|
case 'warning':
|
||||||
|
return { bg: '#fff3cd', border: '#ffeeba', text: '#856404' };
|
||||||
|
case 'error':
|
||||||
|
return { bg: '#f8d7da', border: '#f5c6cb', text: '#721c24' };
|
||||||
|
default:
|
||||||
|
return { bg: '#e2e3e5', border: '#d6d8db', text: '#383d41' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const styles = getTypeStyles();
|
||||||
|
const getAnimationClass = () => {
|
||||||
|
if (!isOpen)
|
||||||
|
return 'opacity-0 translate-y-4';
|
||||||
|
if (isAnimating && onCancel)
|
||||||
|
return 'animate-fade-out';
|
||||||
|
return 'animate-fade-in';
|
||||||
|
};
|
||||||
|
const handleConfirm = () => {
|
||||||
|
setIsAnimating(true);
|
||||||
|
onConfirm?.();
|
||||||
|
setTimeout(() => setIsAnimating(false), 300);
|
||||||
|
};
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsAnimating(true);
|
||||||
|
onCancel?.();
|
||||||
|
setTimeout(() => setIsAnimating(false), 300);
|
||||||
|
};
|
||||||
|
if (!isOpen)
|
||||||
|
return null;
|
||||||
|
return (_jsx("div", { className: "fixed inset-0 bg-black/50 flex items-center justify-center z-[100] p-4", children: _jsxs("div", { className: `bg-white rounded-lg shadow-xl max-w-md w-full transform transition-all duration-300 ${getAnimationClass()}`, role: "alertdialog", "aria-modal": "true", "aria-labelledby": "modal-title", "aria-describedby": "modal-message", children: [_jsx("div", { className: `p-6 border-b ${styles.border}`, children: _jsxs("div", { className: "flex items-center gap-3", children: [type === 'success' && _jsx(Icons.success, { className: "w-5 h-5 text-green-600" }), type === 'warning' && _jsx(Icons.warning, { className: "w-5 h-5 text-yellow-600" }), type === 'error' && _jsx(Icons.error, { className: "w-5 h-5 text-red-600" }), type === 'info' && _jsx(Icons.info, { className: "w-5 h-5 text-blue-600" }), _jsx("h2", { id: "modal-title", className: `text-xl font-semibold ${styles.text}`, children: title })] }) }), _jsx("div", { className: "p-6", children: _jsx("p", { id: "modal-message", className: "text-gray-700 leading-relaxed", children: message }) }), _jsxs("div", { className: `px-6 py-4 flex justify-end gap-3 border-t ${styles.border}`, children: [onCancel && (_jsx("button", { onClick: handleCancel, className: "px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-md transition-colors", children: cancelButtonText })), onConfirm && (_jsx("button", { onClick: handleConfirm, className: `px-4 py-2 text-white rounded-md font-medium transition-colors ${type === 'error'
|
||||||
|
? 'bg-red-600 hover:bg-red-700'
|
||||||
|
: 'bg-blue-600 hover:bg-blue-700'}`, children: confirmButtonText }))] })] }) }));
|
||||||
|
};
|
||||||
|
export const useNotificationModal = () => {
|
||||||
|
const [modalState, setModalState] = useState(null);
|
||||||
|
const openModal = (title, message, type = 'info', onConfirm, onCancel) => {
|
||||||
|
setModalState({
|
||||||
|
isOpen: true,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
});
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (onCancel) {
|
||||||
|
setModalState((prev) => ({ ...prev, isOpen: false }));
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
};
|
||||||
|
const closeModal = () => {
|
||||||
|
setModalState((prev) => prev ? { ...prev, isOpen: false } : null);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
modalState,
|
||||||
|
openModal,
|
||||||
|
closeModal,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export default NotificationModal;
|
||||||
|
//# sourceMappingURL=NotificationModal.js.map
|
||||||
+1
File diff suppressed because one or more lines are too long
Vendored
+4
-2
@@ -7,9 +7,11 @@ export declare class JwtStrategy extends JwtStrategy_base {
|
|||||||
private prisma;
|
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
+756
-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, ForbiddenException } 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,200 @@ 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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
participants: {
|
||||||
|
include: { user: { select: { id: true, name: true, email: true } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async addLocation(tourId, body, req) {
|
||||||
|
const legId = body.legId;
|
||||||
|
console.log(`[USER ACTION] User ${req.user.id} added a NEW VISIT point to Tour ${tourId}: "${body.name}"`);
|
||||||
|
const leg = legId
|
||||||
|
? await this.prisma.leg.findUnique({ where: { id: legId } })
|
||||||
|
: await this.prisma.leg.findFirst({ where: { tourId } });
|
||||||
|
if (!leg)
|
||||||
|
throw new NotFoundException('Không tìm thấy chặng phù hợp để thêm địa điểm');
|
||||||
|
return this.prisma.location.create({
|
||||||
|
data: {
|
||||||
|
name: body.name,
|
||||||
|
address: body.address,
|
||||||
|
latitude: body.latitude,
|
||||||
|
longitude: body.longitude,
|
||||||
|
type: body.type,
|
||||||
|
legId: leg.id,
|
||||||
|
plannedStart: body.plannedStart ? new Date(body.plannedStart) : null,
|
||||||
|
plannedEnd: body.plannedEnd ? new Date(body.plannedEnd) : null,
|
||||||
|
}
|
||||||
|
}).then(async (loc) => {
|
||||||
|
if (body.expenseAmount && Number(body.expenseAmount) > 0) {
|
||||||
|
await this.prisma.expense.create({
|
||||||
|
data: {
|
||||||
|
amount: Number(body.expenseAmount),
|
||||||
|
category: body.expenseCategory || 'OTHER',
|
||||||
|
locationId: loc.id,
|
||||||
|
legId: loc.legId,
|
||||||
|
description: body.expenseDescription || `Chi phí tại ${loc.name}`,
|
||||||
|
note: body.expenseNote || null,
|
||||||
|
paidById: body.paidById || null,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return loc;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async updateTourStartPoint(tourId, body, req) {
|
||||||
|
const { latitude, longitude, name } = body;
|
||||||
|
console.log(`[USER ACTION] User ${req.user.id} set START point for Tour ${tourId}: "${name}" at [${latitude}, ${longitude}]`);
|
||||||
|
await this.prisma.location.deleteMany({
|
||||||
|
where: {
|
||||||
|
leg: { tourId: tourId },
|
||||||
|
plannedStart: new Date(0)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const firstLeg = await this.prisma.leg.findFirst({
|
||||||
|
where: { tourId },
|
||||||
|
orderBy: { sequence: 'asc' }
|
||||||
|
});
|
||||||
|
if (!firstLeg)
|
||||||
|
throw new NotFoundException('Không tìm thấy chặng đầu tiên cho Tour này');
|
||||||
|
return this.prisma.location.create({
|
||||||
|
data: {
|
||||||
|
name: name || 'Điểm xuất phát',
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
type: 'MOVE',
|
||||||
|
legId: firstLeg.id,
|
||||||
|
plannedStart: new Date(0),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async updateTourEndPoint(tourId, body, req) {
|
||||||
|
const { latitude, longitude, name } = body;
|
||||||
|
console.log(`[USER ACTION] User ${req.user.id} set END point for Tour ${tourId}: "${name}" at [${latitude}, ${longitude}]`);
|
||||||
|
await this.prisma.location.deleteMany({
|
||||||
|
where: {
|
||||||
|
leg: { tourId: tourId },
|
||||||
|
plannedEnd: new Date(0)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const lastLeg = await this.prisma.leg.findFirst({
|
||||||
|
where: { tourId },
|
||||||
|
orderBy: { sequence: 'desc' }
|
||||||
|
});
|
||||||
|
if (!lastLeg)
|
||||||
|
throw new NotFoundException('Không tìm thấy chặng cuối cùng cho Tour này');
|
||||||
|
return this.prisma.location.create({
|
||||||
|
data: {
|
||||||
|
name: name || 'Điểm kết thúc',
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
type: 'MOVE',
|
||||||
|
legId: lastLeg.id,
|
||||||
|
plannedEnd: new Date(0),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async initializeLegs(tourId, body) {
|
||||||
|
const { count } = body;
|
||||||
|
if (count <= 0 || count > 20)
|
||||||
|
throw new BadRequestException('Số lượng chặng không hợp lệ (1-20)');
|
||||||
|
const existingLegs = await this.prisma.leg.findMany({
|
||||||
|
where: { tourId },
|
||||||
|
orderBy: { sequence: 'asc' }
|
||||||
|
});
|
||||||
|
const needed = count - existingLegs.length;
|
||||||
|
if (needed > 0) {
|
||||||
|
const createData = Array.from({ length: needed }).map((_, i) => ({
|
||||||
|
tourId,
|
||||||
|
sequence: existingLegs.length + i + 1,
|
||||||
|
note: `Chặng ${existingLegs.length + i + 1}`
|
||||||
|
}));
|
||||||
|
await this.prisma.leg.createMany({ data: createData });
|
||||||
|
}
|
||||||
|
const allLegs = await this.prisma.leg.findMany({
|
||||||
|
where: { tourId },
|
||||||
|
orderBy: { sequence: 'asc' }
|
||||||
|
});
|
||||||
|
const lastLeg = allLegs[allLegs.length - 1];
|
||||||
|
const endPoint = await this.prisma.location.findFirst({
|
||||||
|
where: { leg: { tourId }, plannedEnd: new Date(0) }
|
||||||
|
});
|
||||||
|
if (endPoint && lastLeg && endPoint.legId !== lastLeg.id) {
|
||||||
|
await this.prisma.location.update({
|
||||||
|
where: { id: endPoint.id },
|
||||||
|
data: { legId: lastLeg.id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return allLegs;
|
||||||
|
}
|
||||||
|
async addLeg(tourId, body) {
|
||||||
|
const tour = await this.prisma.tour.findUnique({
|
||||||
|
where: { id: tourId },
|
||||||
|
include: { legs: true }
|
||||||
|
});
|
||||||
|
if (!tour)
|
||||||
|
throw new NotFoundException('Không tìm thấy tour');
|
||||||
|
return this.prisma.leg.create({
|
||||||
|
data: {
|
||||||
|
tourId,
|
||||||
|
sequence: tour.legs.length + 1,
|
||||||
|
note: body.note || `Chặng ${tour.legs.length + 1}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async updateTour(id, body) {
|
||||||
|
return this.prisma.tour.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
title: body.title,
|
||||||
|
startDate: body.startDate ? new Date(body.startDate) : undefined,
|
||||||
|
endDate: body.endDate ? new Date(body.endDate) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async deleteTour(id) {
|
||||||
|
await this.prisma.tour.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
async getPublicTours(req) {
|
||||||
return this.prisma.tour.findMany({
|
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 +309,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 +329,558 @@ 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 } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let currentRole = req.user.tourParticipation?.role;
|
||||||
|
if (!currentRole) {
|
||||||
|
const participation = await this.prisma.tourParticipant.findUnique({
|
||||||
|
where: { tourId_userId: { tourId, userId: req.user.id } },
|
||||||
|
});
|
||||||
|
currentRole = participation?.role;
|
||||||
|
}
|
||||||
|
if (!currentRole || !(currentRole === 'OWNER' || currentRole === 'MANAGER')) {
|
||||||
|
const joinRequest = await this.prisma.joinRequest.create({
|
||||||
|
data: {
|
||||||
|
tourId,
|
||||||
|
userId: body.userId,
|
||||||
|
requestedById: req.user.id,
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
requestedBy: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ...joinRequest, pendingApproval: true };
|
||||||
|
}
|
||||||
|
return this.prisma.tourParticipant.create({
|
||||||
|
data: {
|
||||||
|
tourId,
|
||||||
|
userId: body.userId,
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
include: { user: { select: { id: true, name: true, email: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async getJoinRequests(tourId, req) {
|
||||||
|
const requests = await this.prisma.joinRequest.findMany({
|
||||||
|
where: { tourId, status: 'PENDING' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
requestedBy: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return requests;
|
||||||
|
}
|
||||||
|
async createJoinRequest(tourId, body, req) {
|
||||||
|
const requestingUserId = body.userId || req.user.id;
|
||||||
|
const existingParticipation = await this.prisma.tourParticipant.findUnique({
|
||||||
|
where: { tourId_userId: { tourId, userId: requestingUserId } },
|
||||||
|
});
|
||||||
|
if (existingParticipation) {
|
||||||
|
throw new BadRequestException('Người dùng này đã là thành viên của tour.');
|
||||||
|
}
|
||||||
|
const pendingRequest = await this.prisma.joinRequest.findFirst({
|
||||||
|
where: { tourId, userId: requestingUserId, status: 'PENDING' },
|
||||||
|
});
|
||||||
|
if (pendingRequest) {
|
||||||
|
return pendingRequest;
|
||||||
|
}
|
||||||
|
const joinRequest = await this.prisma.joinRequest.create({
|
||||||
|
data: {
|
||||||
|
tourId,
|
||||||
|
userId: requestingUserId,
|
||||||
|
requestedById: req.user.id,
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
requestedBy: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return joinRequest;
|
||||||
|
}
|
||||||
|
async acceptJoinRequest(tourId, requestId, req) {
|
||||||
|
let role = req.user.tourParticipation?.role;
|
||||||
|
if (!role) {
|
||||||
|
const participation = await this.prisma.tourParticipant.findUnique({
|
||||||
|
where: { tourId_userId: { tourId, userId: req.user.id } },
|
||||||
|
});
|
||||||
|
role = participation?.role;
|
||||||
|
}
|
||||||
|
if (!role || !(role === 'OWNER' || role === 'MANAGER')) {
|
||||||
|
throw new ForbiddenException('Bạn không có quyền chấp nhận yêu cầu tham gia.');
|
||||||
|
}
|
||||||
|
const joinRequest = await this.prisma.joinRequest.findUnique({
|
||||||
|
where: { id: requestId },
|
||||||
|
});
|
||||||
|
if (!joinRequest || joinRequest.tourId !== tourId) {
|
||||||
|
throw new NotFoundException('Không tìm thấy yêu cầu tham gia.');
|
||||||
|
}
|
||||||
|
if (joinRequest.status !== 'PENDING') {
|
||||||
|
throw new BadRequestException('Yêu cầu này đã được xử lý trước đó.');
|
||||||
|
}
|
||||||
|
const existing = await this.prisma.tourParticipant.findUnique({
|
||||||
|
where: { tourId_userId: { tourId, userId: joinRequest.userId } },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
await this.prisma.joinRequest.update({
|
||||||
|
where: { id: requestId },
|
||||||
|
data: { status: 'REJECTED' },
|
||||||
|
});
|
||||||
|
return { success: true, message: 'Người dùng đã là thành viên, yêu cầu đã bị từ chối.' };
|
||||||
|
}
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.tourParticipant.create({
|
||||||
|
data: {
|
||||||
|
tourId,
|
||||||
|
userId: joinRequest.userId,
|
||||||
|
role: 'MEMBER',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.joinRequest.update({
|
||||||
|
where: { id: requestId },
|
||||||
|
data: { status: 'ACCEPTED' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { success: true, message: 'Đã chấp nhận yêu cầu tham gia.' };
|
||||||
|
}
|
||||||
|
async rejectJoinRequest(tourId, requestId, req) {
|
||||||
|
let role = req.user.tourParticipation?.role;
|
||||||
|
if (!role) {
|
||||||
|
const participation = await this.prisma.tourParticipant.findUnique({
|
||||||
|
where: { tourId_userId: { tourId, userId: req.user.id } },
|
||||||
|
});
|
||||||
|
role = participation?.role;
|
||||||
|
}
|
||||||
|
if (!role || !(role === 'OWNER' || role === 'MANAGER')) {
|
||||||
|
throw new ForbiddenException('Bạn không có quyền từ chối yêu cầu tham gia.');
|
||||||
|
}
|
||||||
|
const joinRequest = await this.prisma.joinRequest.findUnique({
|
||||||
|
where: { id: requestId },
|
||||||
|
});
|
||||||
|
if (!joinRequest || joinRequest.tourId !== tourId) {
|
||||||
|
throw new NotFoundException('Không tìm thấy yêu cầu tham gia.');
|
||||||
|
}
|
||||||
|
if (joinRequest.status !== 'PENDING') {
|
||||||
|
throw new BadRequestException('Yêu cầu này đã được xử lý trước đó.');
|
||||||
|
}
|
||||||
|
await this.prisma.joinRequest.update({
|
||||||
|
where: { id: requestId },
|
||||||
|
data: { status: 'REJECTED' },
|
||||||
|
});
|
||||||
|
return { success: true, message: 'Đã từ chối yêu cầu tham gia.' };
|
||||||
|
}
|
||||||
|
async removeMember(tourId, userId) {
|
||||||
|
const participation = await this.prisma.tourParticipant.findUnique({
|
||||||
|
where: { tourId_userId: { tourId, userId } },
|
||||||
|
});
|
||||||
|
if (!participation) {
|
||||||
|
throw new NotFoundException('Thành viên này không có trong tour');
|
||||||
|
}
|
||||||
|
await this.prisma.tourParticipant.delete({
|
||||||
|
where: { tourId_userId: { tourId, userId } },
|
||||||
|
});
|
||||||
|
return { message: 'Đã xóa thành viên khỏi tour' };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
__decorate([
|
__decorate([
|
||||||
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);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||||
|
Get(':tourId/join-requests'),
|
||||||
|
__param(0, Param('tourId', ParseUUIDPipe)),
|
||||||
|
__param(1, Req()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "getJoinRequests", null);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||||
|
Post(':tourId/join-requests'),
|
||||||
|
__param(0, Param('tourId', ParseUUIDPipe)),
|
||||||
|
__param(1, Body()),
|
||||||
|
__param(2, Req()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, Object, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "createJoinRequest", null);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||||
|
Post(':tourId/join-requests/:requestId/accept'),
|
||||||
|
__param(0, Param('tourId', ParseUUIDPipe)),
|
||||||
|
__param(1, Param('requestId')),
|
||||||
|
__param(2, Req()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, String, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "acceptJoinRequest", null);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||||
|
Post(':tourId/join-requests/:requestId/reject'),
|
||||||
|
__param(0, Param('tourId', ParseUUIDPipe)),
|
||||||
|
__param(1, Param('requestId')),
|
||||||
|
__param(2, Req()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, String, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "rejectJoinRequest", null);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||||
|
Delete(':tourId/members/:userId'),
|
||||||
|
__param(0, Param('tourId', ParseUUIDPipe)),
|
||||||
|
__param(1, Param('userId', ParseUUIDPipe)),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, String]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "removeMember", null);
|
||||||
TourController = __decorate([
|
TourController = __decorate([
|
||||||
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 +895,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 +962,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
+11
-7
@@ -16,12 +16,16 @@ let TourRoleGuard = class TourRoleGuard {
|
|||||||
async canActivate(context) {
|
async canActivate(context) {
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const user = request.user;
|
const user = request.user;
|
||||||
const tourId = parseInt(request.params.id || request.params.tourId);
|
const tourId = request.params.id || request.params.tourId;
|
||||||
const path = request.url;
|
const path = request.url;
|
||||||
if (!user || isNaN(tourId)) {
|
if (!user || !tourId) {
|
||||||
throw new ForbiddenException("Thông tin xác thực hoặc mã Tour không hợp lệ.");
|
throw new ForbiddenException("Thông tin xác thực hoặc mã Tour không hợp lệ.");
|
||||||
}
|
}
|
||||||
const membership = await this.prisma.tourMember.findUnique({
|
if (path.includes('/join-requests') && request.method === 'POST' && (!request.params.requestId || /\/join-requests\/[^/]+\/(accept|reject)$/.test(path))) {
|
||||||
|
request.tourParticipation = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const participation = await this.prisma.tourParticipant.findUnique({
|
||||||
where: {
|
where: {
|
||||||
tourId_userId: {
|
tourId_userId: {
|
||||||
tourId: tourId,
|
tourId: tourId,
|
||||||
@@ -29,14 +33,14 @@ let TourRoleGuard = class TourRoleGuard {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!membership) {
|
if (!participation) {
|
||||||
throw new ForbiddenException("Bạn không phải là thành viên của tour này.");
|
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;QAE1B,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,EAAE,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC;QAC1D,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC;QAEzB,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YACrB,MAAM,IAAI,kBAAkB,CAAC,+CAA+C,CAAC,CAAC;QAChF,CAAC;QAED,IAAI,IAAI,CAAC,QAAQ,CAAC,gBAAgB,CAAC,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM,IAAI,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,SAAS,IAAI,0CAA0C,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC;YACzJ,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;YACjC,OAAO,IAAI,CAAC;QACd,CAAC;QAED,MAAM,aAAa,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,eAAe,CAAC,UAAU,CAAC;YACjE,KAAK,EAAE;gBACL,aAAa,EAAE;oBACb,MAAM,EAAE,MAAM;oBACd,MAAM,EAAE,IAAI,CAAC,EAAE;iBAChB;aACF;SACF,CAAC,CAAC;QAEH,IAAI,CAAC,aAAa,EAAE,CAAC;YACnB,MAAM,IAAI,kBAAkB,CAAC,4CAA4C,CAAC,CAAC;QAC7E,CAAC;QAED,OAAO,CAAC,iBAAiB,GAAG,aAAa,CAAC;QAE1C,MAAM,IAAI,GAAG,aAAa,CAAC,IAAI,CAAC;QAChC,MAAM,UAAU,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;QAC3C,MAAM,aAAa,GAAG,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;QAEjD,IACE,CAAC,IAAI,KAAK,mBAAmB,IAAI,IAAI,KAAK,aAAa,CAAC;YACxD,CAAC,UAAU,IAAI,aAAa,CAAC,EAC7B,CAAC;YACD,MAAM,IAAI,kBAAkB,CAAC,0DAA0D,CAAC,CAAC;QAC3F,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AA/CY,aAAa;IADzB,UAAU,EAAE;qCAEiB,aAAa;GAD9B,aAAa,CA+CzB"}
|
||||||
Vendored
+16
-15
@@ -2,6 +2,7 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
import { 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
+27
-2
@@ -3,11 +3,36 @@ 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<any>;
|
||||||
|
removeMember: (tourId: string, userId: string) => Promise<void>;
|
||||||
|
setActiveLegId: (id: string | null) => void;
|
||||||
|
setMapCenter: (pos: [number, number]) => void;
|
||||||
|
fetchTour: (id: string) => Promise<void>;
|
||||||
fetchPublicTours: () => Promise<void>;
|
fetchPublicTours: () => Promise<void>;
|
||||||
|
createJoinRequest: (tourId: string, userId?: string) => Promise<any>;
|
||||||
|
fetchJoinRequests: (tourId: string) => Promise<any[]>;
|
||||||
|
acceptJoinRequest: (tourId: string, requestId: string) => Promise<any>;
|
||||||
|
rejectJoinRequest: (tourId: string, requestId: string) => Promise<any>;
|
||||||
}
|
}
|
||||||
export declare const useTourStore: import("zustand").UseBoundStore<import("zustand").StoreApi<TourState>>;
|
export declare const useTourStore: import("zustand").UseBoundStore<import("zustand").StoreApi<TourState>>;
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
Vendored
+328
-7
@@ -4,21 +4,342 @@ 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');
|
||||||
const data = await response.json();
|
try {
|
||||||
const role = data.members?.find((m) => m.userId === currentUserId)?.role || 'VIEWER_EXTERNAL';
|
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
|
||||||
set({ currentTour: data, legs: data.legs || [], userRole: role });
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
const data = await response.json();
|
||||||
|
let role = 'VIEWER_ONLY';
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
const currentUserId = payload.sub;
|
||||||
|
const participant = data.participants?.find((p) => p.userId === currentUserId);
|
||||||
|
if (participant)
|
||||||
|
role = participant.role;
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error("Lỗi khi xác định vai trò người dùng:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const legs = data.legs || [];
|
||||||
|
set({ currentTour: data, legs, userRole: role, activeLegId: legs.length > 0 ? legs[0].id : null });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('Không thể tải tour:', err);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
fetchPublicTours: async () => {
|
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),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.message || data.error || 'Lỗi khi tạo Tour');
|
||||||
|
}
|
||||||
|
const tour = await response.json();
|
||||||
|
await get().fetchPublicTours();
|
||||||
|
return tour;
|
||||||
|
},
|
||||||
|
updateTour: async (id, data) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Lỗi khi cập nhật Tour');
|
||||||
|
get().fetchPublicTours();
|
||||||
|
},
|
||||||
|
deleteTour: async (id) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.message || 'Lỗi khi xóa Tour');
|
||||||
|
}
|
||||||
|
if (get().currentTour?.id === id)
|
||||||
|
set({ currentTour: null });
|
||||||
|
get().fetchPublicTours();
|
||||||
|
},
|
||||||
|
addLeg: async (tourId, data) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/legs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Lỗi khi thêm chặng');
|
||||||
|
get().fetchTour(tourId);
|
||||||
|
},
|
||||||
|
initializeLegs: async (tourId, count) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/legs/batch`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ count }),
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Lỗi khi khởi tạo chặng');
|
||||||
|
await get().fetchTour(tourId);
|
||||||
|
},
|
||||||
|
updateLeg: async (legId, data) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/legs/${legId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Lỗi khi cập nhật chặng');
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour)
|
||||||
|
get().fetchTour(currentTour.id);
|
||||||
|
},
|
||||||
|
deleteLeg: async (legId) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/legs/${legId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.message || 'Lỗi khi xóa chặng');
|
||||||
|
}
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour)
|
||||||
|
get().fetchTour(currentTour.id);
|
||||||
|
},
|
||||||
|
addLocation: async (tourId, locationData) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/locations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(locationData),
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Lỗi khi thêm địa điểm');
|
||||||
|
get().fetchTour(tourId);
|
||||||
|
},
|
||||||
|
updateLocation: async (locationId, data) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/locations/${locationId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Lỗi khi cập nhật địa điểm');
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour)
|
||||||
|
get().fetchTour(currentTour.id);
|
||||||
|
},
|
||||||
|
deleteLocation: async (locationId) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/locations/${locationId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Lỗi khi xóa địa điểm');
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour)
|
||||||
|
get().fetchTour(currentTour.id);
|
||||||
|
},
|
||||||
|
updateTourStartPoint: async (tourId, data) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/start-point`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
console.log(`[STORE] updateTourStartPoint API Status: ${response.status}`);
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Lỗi khi thiết lập điểm bắt đầu');
|
||||||
|
await get().fetchTour(tourId);
|
||||||
|
},
|
||||||
|
updateTourEndPoint: async (tourId, data) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/end-point`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Lỗi khi thiết lập điểm kết thúc');
|
||||||
|
await get().fetchTour(tourId);
|
||||||
|
},
|
||||||
|
optimizeRouting: async (legId) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/routing/optimize/${legId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const { locations, totalDistance } = await response.json();
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour) {
|
||||||
|
const updatedLegs = currentTour.legs.map((l) => l.id === legId ? { ...l, locations: locations, totalDistance: totalDistance } : l);
|
||||||
|
set({ currentTour: { ...currentTour, legs: updatedLegs }, legs: updatedLegs });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeMember: async (tourId, userId) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Lỗi khi xóa thành viên');
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour)
|
||||||
|
get().fetchTour(currentTour.id);
|
||||||
|
},
|
||||||
|
addMember: async (tourId, member) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(member),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.message || data.error || 'Lỗi khi thêm thành viên');
|
||||||
|
}
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour)
|
||||||
|
get().fetchTour(currentTour.id);
|
||||||
|
},
|
||||||
|
createJoinRequest: async (tourId, userId) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ userId }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.message || data.error || 'Lỗi khi tạo yêu cầu tham gia');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
fetchJoinRequests: async (tourId) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.message || data.error || 'Lỗi khi tải yêu cầu tham gia');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
acceptJoinRequest: async (tourId, requestId) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests/${requestId}/accept`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.message || data.error || 'Lỗi khi chấp nhận yêu cầu');
|
||||||
|
}
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour)
|
||||||
|
get().fetchTour(currentTour.id);
|
||||||
|
},
|
||||||
|
rejectJoinRequest: async (tourId, requestId) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests/${requestId}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.message || data.error || 'Lỗi khi từ chối yêu cầu');
|
||||||
|
}
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour)
|
||||||
|
get().fetchTour(currentTour.id);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
//# sourceMappingURL=useTourStore.js.map
|
//# sourceMappingURL=useTourStore.js.map
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user