Compare commits
78 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 | |||
| 7fc80a49d0 | |||
| 7b42058d77 | |||
| ea5799ffb5 | |||
| f38d7e01a1 | |||
| ee5a5b6800 | |||
| 6913bbae48 | |||
| c08aad3f68 | |||
| 1236d00867 | |||
| e1cbd49728 | |||
| 6383965bfa |
@@ -0,0 +1,8 @@
|
||||
name: Local Config
|
||||
version: 1.0.0
|
||||
schema: v1
|
||||
models:
|
||||
- name: Autodetect
|
||||
provider: lmstudio
|
||||
model: AUTODETECT
|
||||
apiBase: http://192.168.1.12:1234/v1/
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://app.kilo.ai/config.json"
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"continue.enableConsole": true
|
||||
}
|
||||
-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,38 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { LandingPage } from './LandingPage';
|
||||
import { TourDetailPage } from './TourDetailPage';
|
||||
import { ExploreMap } from './ExploreMap';
|
||||
import { SignupPage } from './SignupPage';
|
||||
|
||||
const App = () => {
|
||||
type View = 'landing' | 'explore' | 'detail' | 'signup';
|
||||
const [view, setView] = useState<View>('landing');
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
{view === 'landing' && (
|
||||
<LandingPage
|
||||
onContinue={() => setView('explore')}
|
||||
onGoToSignup={() => setView('signup')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'signup' && (
|
||||
<SignupPage
|
||||
onBack={() => setView('landing')}
|
||||
onSuccess={() => setView('landing')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'explore' && (
|
||||
<ExploreMap onBack={() => setView('landing')} />
|
||||
)}
|
||||
|
||||
{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';
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,93 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { useTourStore } from './useTourStore';
|
||||
import { X, Navigation, Image as ImageIcon } from 'lucide-react';
|
||||
|
||||
// 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;
|
||||
|
||||
export const ExploreMap = ({ onBack }: { onBack: () => void }) => {
|
||||
const { publicTours, fetchPublicTours } = useTourStore();
|
||||
const [userPos, setUserPos] = useState<[number, number]>([10.7769, 106.7009]); // Mặc định TP.HCM
|
||||
|
||||
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>
|
||||
|
||||
{/* 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'
|
||||
/>
|
||||
|
||||
{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>
|
||||
</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';
|
||||
|
||||
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,92 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { LogIn, Compass, ArrowRight, Camera } from 'lucide-react';
|
||||
import { LoginModal } from './LoginModal';
|
||||
|
||||
interface LandingPageProps {
|
||||
onContinue?: () => void;
|
||||
onGoToSignup?: () => void;
|
||||
}
|
||||
|
||||
export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSignup }) => {
|
||||
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col md:flex-row overflow-hidden font-sans relative">
|
||||
{/* Nửa bên trái: Ảnh background mặc định của dự án */}
|
||||
<div className="hidden md:block md:w-1/2 relative bg-gray-900">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1503220317375-aaad61436b1b?q=80&w=2070&auto=format&fit=crop"
|
||||
alt="Travel Planner Background"
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-70"
|
||||
/>
|
||||
{/* Overlay màu xanh nhẹ đặc trưng của dự án */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-blue-900/60 to-transparent" />
|
||||
|
||||
<div className="absolute bottom-16 left-16 text-white z-10">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-3 bg-white/20 backdrop-blur-md rounded-2xl">
|
||||
<Compass className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<span className="text-3xl font-black tracking-tighter uppercase">Travel Planner</span>
|
||||
</div>
|
||||
<h2 className="text-5xl font-bold leading-tight max-w-lg">
|
||||
Khám phá thế giới, lưu giữ kỉ niệm.
|
||||
</h2>
|
||||
<p className="mt-4 text-blue-100 text-lg opacity-80 max-w-sm">
|
||||
Hợp tác cùng bạn bè để lên lịch trình hoàn hảo nhất cho mọi chuyến đi.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nửa bên phải: Nội dung chào mừng và Hành động */}
|
||||
<div className="flex-1 flex flex-col justify-center items-center bg-white p-8 sm:p-12 md:p-20">
|
||||
<div className="max-w-md w-full space-y-12">
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-4xl md:text-5xl font-extrabold text-gray-900 leading-tight">
|
||||
Chào mừng bạn đến với trang tạo kế hoạch du lịch
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 leading-relaxed">
|
||||
Đăng nhập để vào tạo kế hoạch cá nhân hoặc nhấn nút tiếp tục để tham quan các hình ảnh du lịch được chia sẻ.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Nút Đăng nhập */}
|
||||
<button
|
||||
onClick={() => setIsLoginModalOpen(true)}
|
||||
className="group flex items-center justify-center gap-3 bg-blue-600 hover:bg-blue-700 text-white font-bold py-5 px-8 rounded-2xl shadow-xl shadow-blue-200 transition-all active:scale-95"
|
||||
>
|
||||
<LogIn className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
Đăng nhập ngay
|
||||
</button>
|
||||
|
||||
{/* Nút Tiếp tục */}
|
||||
<button
|
||||
onClick={onContinue}
|
||||
className="flex items-center justify-center gap-3 bg-gray-50 hover:bg-gray-100 text-gray-700 font-bold py-5 px-8 rounded-2xl border border-gray-200 transition-all active:scale-95"
|
||||
>
|
||||
Tiếp tục tham quan ảnh
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-12 flex items-center gap-4 text-sm text-gray-400 border-t border-gray-100">
|
||||
<div className="flex -space-x-3">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<img key={i} className="w-10 h-10 rounded-full border-4 border-white" src={`https://i.pravatar.cc/100?img=${i+20}`} alt="user" />
|
||||
))}
|
||||
</div>
|
||||
<p className="font-medium">Tham gia cùng +2,400 người du lịch khác</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login Modal Component */}
|
||||
<LoginModal
|
||||
isOpen={isLoginModalOpen}
|
||||
onClose={() => setIsLoginModalOpen(false)}
|
||||
onSwitchToSignup={onGoToSignup}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,137 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ItineraryTimeline } from './ItineraryTimeline';
|
||||
import { ExpenseManager } from './ExpenseManager';
|
||||
import { useTourStore } from './useTourStore';
|
||||
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",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": ".",
|
||||
"sourceRoot": "src",
|
||||
"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 { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { Pool } from 'pg';
|
||||
import 'dotenv/config';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
private pool: Pool;
|
||||
|
||||
constructor() {
|
||||
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 adapter = new PrismaPg(pool);
|
||||
super({ adapter });
|
||||
this.pool = pool;
|
||||
}
|
||||
|
||||
async onModuleInit() { await this.$connect(); }
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
async onModuleDestroy() { await this.$disconnect(); }
|
||||
}
|
||||
@@ -0,0 +1,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 { PrismaPg } from '@prisma/adapter-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 adapter = new PrismaPg(pool);
|
||||
@@ -20,7 +25,9 @@ async function main() {
|
||||
data: {
|
||||
email: 'owner@travel.com',
|
||||
name: 'Lộc Phạm (Chủ Tour)',
|
||||
passwordHash: 'hashed_password_123',
|
||||
// Hash mật khẩu '123456' để có thể đăng nhập thực tế
|
||||
passwordHash: await bcrypt.hash('123456', 10),
|
||||
isAdmin: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -28,7 +35,7 @@ async function main() {
|
||||
data: {
|
||||
email: 'photomember@travel.com',
|
||||
name: 'Nguyễn Văn Ảnh (Chỉ xem ảnh)',
|
||||
passwordHash: 'hashed_password_456',
|
||||
passwordHash: await bcrypt.hash('123456', 10),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -38,11 +45,11 @@ async function main() {
|
||||
title: 'Hành trình khám phá TP.HCM',
|
||||
startDate: new Date('2023-11-20'),
|
||||
endDate: new Date('2023-11-21'),
|
||||
creatorId: owner.id,
|
||||
members: {
|
||||
createdById: owner.id,
|
||||
participants: {
|
||||
create: [
|
||||
{ userId: owner.id, role: 'OWNER' },
|
||||
{ userId: photoMember.id, role: 'MEMBER_PHOTO_ONLY' },
|
||||
{ userId: photoMember.id, role: 'VIEWER_ONLY' },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -52,25 +59,23 @@ async function main() {
|
||||
const leg1 = await prisma.leg.create({
|
||||
data: {
|
||||
tourId: tour.id,
|
||||
sequenceNumber: 1,
|
||||
notes: 'Khám phá lịch sử trung tâm',
|
||||
places: {
|
||||
sequence: 1,
|
||||
note: 'Khám phá lịch sử trung tâm',
|
||||
locations: {
|
||||
create: [
|
||||
{
|
||||
name: 'Dinh Độc Lập',
|
||||
address: '135 Nam Kỳ Khởi Nghĩa, Quận 1',
|
||||
latitude: 10.777,
|
||||
longitude: 106.695,
|
||||
sequenceInLeg: 1,
|
||||
arrivalTime: new Date('2023-11-20T08:00:00Z'),
|
||||
plannedStart: new Date('2023-11-20T08:00:00Z'),
|
||||
},
|
||||
{
|
||||
name: 'Bưu điện Thành phố',
|
||||
address: '02 Công xã Paris, Quận 1',
|
||||
latitude: 10.779,
|
||||
longitude: 106.699,
|
||||
sequenceInLeg: 2,
|
||||
arrivalTime: new Date('2023-11-20T10:00:00Z'),
|
||||
plannedStart: new Date('2023-11-20T10:00:00Z'),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -78,16 +83,36 @@ async function main() {
|
||||
});
|
||||
|
||||
console.log('--- Đang tạo chi phí mẫu... ---');
|
||||
await prisma.expense.create({
|
||||
const expense1 = await prisma.expense.create({
|
||||
data: {
|
||||
legId: leg1.id,
|
||||
category: 'DINING',
|
||||
category: 'FOOD',
|
||||
amount: 500000,
|
||||
currency: 'VND',
|
||||
description: 'Ăn trưa đặc sản Quận 1',
|
||||
note: 'Đặt trước cho 3 người',
|
||||
paidById: owner.id,
|
||||
},
|
||||
});
|
||||
|
||||
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(`Email đăng nhập Owner: ${owner.email}`);
|
||||
console.log(`Email đăng nhập Photo Only: ${photoMember.email}`);
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
// Lưu ý: request.user thường được đính kèm bởi một AuthGuard (JWT/Passport) chạy trước đó.
|
||||
const user = request.user;
|
||||
|
||||
if (!user || !user.id) {
|
||||
throw new ForbiddenException('Yêu cầu xác thực không hợp lệ. Vui lòng đăng nhập.');
|
||||
}
|
||||
|
||||
// Kiểm tra trực tiếp từ database để đảm bảo quyền isAdmin là chính xác nhất cho các tác vụ nhạy cảm
|
||||
const dbUser = await this.prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { isAdmin: true, isBlocked: true },
|
||||
});
|
||||
|
||||
if (!dbUser || !dbUser.isAdmin || dbUser.isBlocked) {
|
||||
throw new ForbiddenException('Truy cập bị từ chối. Bạn không có quyền quản trị viên hệ thống.');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private prisma: PrismaService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_SECRET || 'super-secret',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Người dùng không tồn tại hoặc phiên làm việc hết hạn');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -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 { PrismaService } from './prisma.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class TourRoleGuard implements CanActivate {
|
||||
@@ -7,22 +7,21 @@ export class TourRoleGuard implements CanActivate {
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user; // Giả sử đã qua AuthGuard (Passport/JWT)
|
||||
const user = request.user;
|
||||
|
||||
// Lấy tourId từ URL params (:id hoặc :tourId)
|
||||
const tourId = parseInt(request.params.id || request.params.tourId);
|
||||
const tourId = request.params.id || request.params.tourId;
|
||||
const path = request.url;
|
||||
|
||||
if (!user || isNaN(tourId)) {
|
||||
if (!user || !tourId) {
|
||||
throw new ForbiddenException("Thông tin xác thực hoặc mã Tour không hợp lệ.");
|
||||
}
|
||||
|
||||
/**
|
||||
* TỐI ƯU: Chỉ truy vấn Database 1 lần duy nhất để lấy thông tin thành viên.
|
||||
* Chúng ta lưu kết quả vào request object để các interceptor hoặc controller
|
||||
* sau này có thể dùng lại mà không cần query lại.
|
||||
*/
|
||||
const membership = await this.prisma.tourMember.findUnique({
|
||||
if (path.includes('/join-requests') && request.method === 'POST' && (!request.params.requestId || /\/join-requests\/[^/]+\/(accept|reject)$/.test(path))) {
|
||||
request.tourParticipation = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
const participation = await this.prisma.tourParticipant.findUnique({
|
||||
where: {
|
||||
tourId_userId: {
|
||||
tourId: tourId,
|
||||
@@ -31,20 +30,18 @@ export class TourRoleGuard implements CanActivate {
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
if (!participation) {
|
||||
throw new ForbiddenException("Bạn không phải là thành viên của tour này.");
|
||||
}
|
||||
|
||||
// Gắn thông tin vào request để sử dụng ở tầng Controller
|
||||
request.tourMembership = membership;
|
||||
request.tourParticipation = participation;
|
||||
|
||||
const role = membership.role;
|
||||
const role = participation.role;
|
||||
const isPlanPath = path.includes('/plans');
|
||||
const isExpensePath = path.includes('/expenses');
|
||||
|
||||
// 1. Chặn MEMBER_PHOTO_ONLY và VIEWER_EXTERNAL truy cập Plans & Expenses
|
||||
if (
|
||||
(role === 'MEMBER_PHOTO_ONLY' || role === 'VIEWER_EXTERNAL') &&
|
||||
(role === 'MEMBER_NO_FINANCE' || role === 'VIEWER_ONLY') &&
|
||||
(isPlanPath || isExpensePath)
|
||||
) {
|
||||
throw new ForbiddenException("Bạn không có quyền xem kế hoạch và chi phí của tour này.");
|
||||
+1401
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "node", // Đảm bảo module resolution là 'node'
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
@@ -10,13 +10,18 @@
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": 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
+43
-11
@@ -1,15 +1,47 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const jsx_runtime_1 = require("react/jsx-runtime");
|
||||
const react_1 = require("react");
|
||||
const LandingPage_1 = require("./LandingPage");
|
||||
const TourDetailPage_1 = require("./TourDetailPage");
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { LandingPage } from './LandingPage.js';
|
||||
import { TourDetailPage } from './TourDetailPage.js';
|
||||
import { ExploreMap } from './ExploreMap.js';
|
||||
import { SignupPage } from './SignupPage.js';
|
||||
import { useTourStore } from './useTourStore.js';
|
||||
const App = () => {
|
||||
const [isStarted, setIsStarted] = (0, react_1.useState)(false);
|
||||
const handleContinue = () => {
|
||||
setIsStarted(true);
|
||||
const [view, setView] = useState('landing');
|
||||
const [isInitialSetup, setIsInitialSetup] = useState(false);
|
||||
const [user, setUser] = useState(null);
|
||||
const [isUserLoaded, setIsUserLoaded] = useState(false);
|
||||
const fetchTour = useTourStore(state => state.fetchTour);
|
||||
useEffect(() => {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const savedUser = localStorage.getItem('user');
|
||||
if (savedUser) {
|
||||
const parsedUser = JSON.parse(savedUser);
|
||||
setUser(parsedUser);
|
||||
}
|
||||
setIsUserLoaded(true);
|
||||
fetch(`${API_BASE}/api/v1/auth/status`)
|
||||
.then(res => res.ok ? res.json() : Promise.reject())
|
||||
.then(data => setIsInitialSetup(!!data.isInitialSetup))
|
||||
.catch(() => setIsInitialSetup(false));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (isUserLoaded && user && view === 'landing') {
|
||||
setView('explore');
|
||||
}
|
||||
}, [isUserLoaded, user, view]);
|
||||
const handleLoginSuccess = (userData) => {
|
||||
setUser(userData);
|
||||
};
|
||||
return ((0, jsx_runtime_1.jsx)("div", { className: "app-container", children: !isStarted ? ((0, jsx_runtime_1.jsx)(LandingPage_1.LandingPage, { onContinue: handleContinue })) : ((0, jsx_runtime_1.jsx)(TourDetailPage_1.TourDetailPage, {})) }));
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('token');
|
||||
setUser(null);
|
||||
setView('landing');
|
||||
};
|
||||
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') }))] }));
|
||||
};
|
||||
exports.default = App;
|
||||
export default App;
|
||||
//# sourceMappingURL=App.js.map
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"App.js","sourceRoot":"","sources":["../App.tsx"],"names":[],"mappings":";;;AAAA,iCAAwC;AACxC,+CAA4C;AAC5C,qDAAkD;AAElD,MAAM,GAAG,GAAG,GAAG,EAAE;IAGf,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,GAAG,IAAA,gBAAQ,EAAC,KAAK,CAAC,CAAC;IAElD,MAAM,cAAc,GAAG,GAAG,EAAE;QAC1B,YAAY,CAAC,IAAI,CAAC,CAAC;IACrB,CAAC,CAAC;IAEF,OAAO,CACL,gCAAK,SAAS,EAAC,eAAe,YAC3B,CAAC,SAAS,CAAC,CAAC,CAAC,CACZ,uBAAC,yBAAW,IAAC,UAAU,EAAE,cAAc,GAAI,CAC5C,CAAC,CAAC,CAAC,CACF,uBAAC,+BAAc,KAAG,CACnB,GACG,CACP,CAAC;AACJ,CAAC,CAAC;AAEF,kBAAe,GAAG,CAAC"}
|
||||
{"version":3,"file":"App.js","sourceRoot":"","sources":["../App.tsx"],"names":[],"mappings":"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"}
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
export declare const ConfirmModal: React.FC<ConfirmModalProps>;
|
||||
export {};
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
export const ConfirmModal = ({ isOpen, title = 'Xác nhận', message, confirmText = 'Xác nhận', cancelText = 'Hủy', onConfirm, onCancel, }) => {
|
||||
if (!isOpen)
|
||||
return null;
|
||||
return (_jsxs("div", { className: "fixed inset-0 z-[2200] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/70 backdrop-blur-sm", onClick: onCancel }), _jsxs("div", { className: "relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5", children: [_jsx("h3", { className: "text-base font-bold text-gray-900", children: title }), _jsx("p", { className: "mt-2 text-sm text-gray-600", children: message }), _jsxs("div", { className: "mt-4 flex justify-end gap-2", children: [_jsx("button", { onClick: onCancel, className: "px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors", children: cancelText }), _jsx("button", { onClick: onConfirm, className: "px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors", children: confirmText })] })] })] }));
|
||||
};
|
||||
//# sourceMappingURL=ConfirmModal.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"ConfirmModal.js","sourceRoot":"","sources":["../ConfirmModal.tsx"],"names":[],"mappings":";AAaA,MAAM,CAAC,MAAM,YAAY,GAAgC,CAAC,EACxD,MAAM,EACN,KAAK,GAAG,UAAU,EAClB,OAAO,EACP,WAAW,GAAG,UAAU,EACxB,UAAU,GAAG,KAAK,EAClB,SAAS,EACT,QAAQ,GACT,EAAE,EAAE;IACH,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAC;IAEzB,OAAO,CACL,eAAK,SAAS,EAAC,6DAA6D,aAC1E,cAAK,SAAS,EAAC,kDAAkD,EAAC,OAAO,EAAE,QAAQ,GAAI,EACvF,eAAK,SAAS,EAAC,8DAA8D,aAC3E,aAAI,SAAS,EAAC,mCAAmC,YAAE,KAAK,GAAM,EAC9D,YAAG,SAAS,EAAC,4BAA4B,YAAE,OAAO,GAAK,EACvD,eAAK,SAAS,EAAC,6BAA6B,aAC1C,iBAAQ,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAC,0FAA0F,YAC5H,UAAU,GACJ,EACT,iBAAQ,OAAO,EAAE,SAAS,EAAE,SAAS,EAAC,iGAAiG,YACpI,WAAW,GACL,IACL,IACF,IACF,CACP,CAAC;AACJ,CAAC,CAAC"}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
export declare const CreateTourModal: ({ isOpen, onClose, onSuccess }: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (tour: any) => void;
|
||||
}) => React.JSX.Element;
|
||||
Vendored
+66
@@ -0,0 +1,66 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { useTourStore } from './useTourStore.js';
|
||||
export const CreateTourModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const createTour = useTourStore((state) => state.createTour);
|
||||
const [members, setMembers] = useState([]);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
if (!isOpen)
|
||||
return null;
|
||||
const searchUsers = async (value) => {
|
||||
setQuery(value);
|
||||
if (!value.trim()) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const res = await fetch(`${API_BASE}/api/v1/users?q=${encodeURIComponent(value)}`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error('Không thể tải người dùng');
|
||||
const data = await res.json();
|
||||
setResults(Array.isArray(data) ? data : []);
|
||||
}
|
||||
catch (e) {
|
||||
setResults([]);
|
||||
setError(e.message || 'Không thể tải người dùng');
|
||||
}
|
||||
};
|
||||
const confirmAddMember = (user) => {
|
||||
setMembers((prev) => (prev.some((m) => m.id === user.id) ? prev : [...prev, user]));
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setError('');
|
||||
};
|
||||
const removeMember = (userId) => {
|
||||
setMembers((prev) => prev.filter((m) => m.id !== userId));
|
||||
};
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const memberIds = members.map((m) => m.id);
|
||||
const tour = await createTour({ title, startDate, endDate, memberIds });
|
||||
onSuccess(tour);
|
||||
onClose();
|
||||
}
|
||||
catch (e) {
|
||||
setError(e.message || 'Lỗi khi tạo tour');
|
||||
}
|
||||
finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
return (_jsxs("div", { className: "fixed inset-0 z-[2000] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/60 backdrop-blur-sm", onClick: onClose }), _jsxs("div", { className: "relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden p-6", children: [_jsxs("div", { className: "flex justify-between items-center mb-4", children: [_jsx("h2", { className: "text-xl font-bold text-gray-900", children: "T\u1EA1o Tour m\u1EDBi" }), _jsx("button", { onClick: onClose, className: "p-2 hover:bg-gray-100 rounded-full transition-colors", children: "\u2715" })] }), _jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [_jsxs("div", { children: [_jsx("label", { className: "block text-sm font-bold text-gray-700 mb-1", children: "T\u00EAn Tour" }), _jsx("input", { required: true, className: "w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500", value: title, onChange: (e) => setTitle(e.target.value), placeholder: "VD: Kh\u00E1m ph\u00E1 \u0110\u00E0 L\u1EA1t" })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { children: [_jsx("label", { className: "block text-sm font-bold text-gray-700 mb-1", children: "B\u1EAFt \u0111\u1EA7u" }), _jsx("input", { type: "date", className: "w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500", value: startDate, onChange: (e) => setStartDate(e.target.value) })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-sm font-bold text-gray-700 mb-1", children: "K\u1EBFt th\u00FAc" }), _jsx("input", { type: "date", className: "w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500", value: endDate, onChange: (e) => setEndDate(e.target.value) })] })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-sm font-bold text-gray-700 mb-2", children: "Th\u00E0nh vi\u00EAn tham gia" }), _jsxs("div", { className: "flex flex-wrap gap-3", children: [members.map((m) => (_jsxs("div", { className: "relative", children: [_jsx("div", { className: "w-12 h-12 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden", children: m.name }), _jsx("button", { type: "button", onClick: () => removeMember(m.id), className: "absolute -top-1 -right-1 w-5 h-5 rounded-full bg-gray-500 hover:bg-red-600 text-white flex items-center justify-center transition-colors", "aria-label": "Remove item", children: _jsx(Trash2, { size: 12 }) }), _jsx("div", { className: "text-[10px] text-center mt-1 max-w-[70px] truncate", children: m.name })] }, m.id))), _jsxs("div", { className: "relative", children: [_jsx("input", { className: "w-36 px-2 py-1 text-sm border border-gray-200 rounded-lg outline-none", placeholder: "T\u00ECm email...", value: query, onChange: (e) => searchUsers(e.target.value) }), results.length > 0 && (_jsx("div", { className: "absolute top-full left-0 right-0 mt-2 bg-white border border-gray-100 rounded-xl shadow-lg z-10 max-h-60 overflow-y-auto", children: results.map((u) => (_jsxs("button", { type: "button", onClick: () => confirmAddMember(u), className: "w-full text-left px-3 py-2 text-sm hover:bg-blue-50", children: [_jsx("span", { className: "font-bold text-gray-900", children: u.name }), _jsx("span", { className: "block text-xs text-gray-500", children: u.email })] }, u.id))) }))] })] }), error && _jsx("p", { className: "text-xs text-red-600 mt-2", children: error })] }), _jsx("button", { type: "submit", disabled: isLoading, className: "w-full py-3 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl shadow-lg transition-all flex items-center justify-center gap-2", children: isLoading ? 'Đang tạo...' : 'Xác nhận tạo Tour' })] })] })] }));
|
||||
};
|
||||
//# sourceMappingURL=CreateTourModal.js.map
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+11
-15
@@ -1,16 +1,13 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ExpenseManager = void 0;
|
||||
const jsx_runtime_1 = require("react/jsx-runtime");
|
||||
const react_1 = require("react");
|
||||
const lucide_react_1 = require("lucide-react");
|
||||
const useTourStore_1 = require("./useTourStore");
|
||||
const ExpenseManager = () => {
|
||||
const { legs } = (0, useTourStore_1.useTourStore)();
|
||||
const [adults, setAdults] = (0, react_1.useState)(2);
|
||||
const [children, setChildren] = (0, react_1.useState)(1);
|
||||
const [discount, setDiscount] = (0, react_1.useState)(30);
|
||||
const totals = (0, react_1.useMemo)(() => {
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { 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, exp) => lAcc + Number(exp.amount), 0), 0);
|
||||
const childRateFactor = 1 - (discount / 100);
|
||||
const weightedCount = adults + (children * childRateFactor);
|
||||
@@ -22,7 +19,6 @@ const ExpenseManager = () => {
|
||||
childPrice: Math.round(childPrice)
|
||||
};
|
||||
}, [legs, adults, children, discount]);
|
||||
return ((0, jsx_runtime_1.jsxs)("div", { className: "space-y-6 animate-in fade-in slide-in-from-bottom-4", children: [(0, jsx_runtime_1.jsxs)("div", { className: "bg-white p-6 rounded-2xl border border-gray-100 shadow-sm", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2 mb-6 text-blue-600", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Users, { className: "w-5 h-5" }), (0, jsx_runtime_1.jsx)("h3", { className: "font-bold", children: "C\u1EA5u h\u00ECnh th\u00E0nh vi\u00EAn" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "grid grid-cols-3 gap-4", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { className: "text-xs text-gray-400 block mb-1", children: "Ng\u01B0\u1EDDi l\u1EDBn" }), (0, jsx_runtime_1.jsx)("input", { type: "number", value: adults, onChange: e => setAdults(Number(e.target.value)), className: "w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" })] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { className: "text-xs text-gray-400 block mb-1", children: "Tr\u1EBB em" }), (0, jsx_runtime_1.jsx)("input", { type: "number", value: children, onChange: e => setChildren(Number(e.target.value)), className: "w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" })] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { className: "text-xs text-gray-400 block mb-1", children: "Gi\u1EA3m tr\u1EBB em (%)" }), (0, jsx_runtime_1.jsx)("input", { type: "number", value: discount, onChange: e => setDiscount(Number(e.target.value)), className: "w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" })] })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "bg-blue-600 rounded-2xl p-6 text-white shadow-lg shadow-blue-200", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex justify-between items-start mb-8", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("p", { className: "text-blue-100 text-sm", children: "T\u1ED5ng chi ph\u00ED chuy\u1EBFn \u0111i" }), (0, jsx_runtime_1.jsxs)("h2", { className: "text-3xl font-bold mt-1", children: [totals.total.toLocaleString(), " VND"] })] }), (0, jsx_runtime_1.jsx)(lucide_react_1.Wallet, { className: "w-8 h-8 opacity-20" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "grid grid-cols-2 gap-4 border-t border-blue-500 pt-6", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("p", { className: "text-blue-100 text-xs uppercase tracking-wider font-semibold", children: "M\u1ED7i ng\u01B0\u1EDDi l\u1EDBn" }), (0, jsx_runtime_1.jsxs)("p", { className: "text-xl font-bold", children: [totals.adultPrice.toLocaleString(), "\u0111"] })] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsxs)("p", { className: "text-blue-100 text-xs uppercase tracking-wider font-semibold", children: ["M\u1ED7i tr\u1EBB em (-", discount, "%)"] }), (0, jsx_runtime_1.jsxs)("p", { className: "text-xl font-bold", children: [totals.childPrice.toLocaleString(), "\u0111"] })] })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2 text-gray-400 text-xs px-2", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Info, { className: "w-4 h-4" }), (0, jsx_runtime_1.jsx)("p", { children: "Chi ph\u00ED \u0111\u01B0\u1EE3c t\u1EF1 \u0111\u1ED9ng t\u00EDnh to\u00E1n d\u1EF1a tr\u00EAn h\u00F3a \u0111\u01A1n c\u1EE7a c\u00E1c ch\u1EB7ng." })] })] }));
|
||||
return (_jsxs("div", { className: "space-y-6 animate-in fade-in slide-in-from-bottom-4", children: [_jsxs("div", { className: "bg-white p-6 rounded-2xl border border-gray-100 shadow-sm", children: [_jsxs("div", { className: "flex items-center gap-2 mb-6 text-blue-600", children: [_jsx(Users, { className: "w-5 h-5" }), _jsx("h3", { className: "font-bold", children: "C\u1EA5u h\u00ECnh th\u00E0nh vi\u00EAn" })] }), _jsxs("div", { className: "grid grid-cols-3 gap-4", children: [_jsxs("div", { children: [_jsx("label", { className: "text-xs text-gray-400 block mb-1", children: "Ng\u01B0\u1EDDi l\u1EDBn" }), _jsx("input", { type: "number", value: adults, onChange: e => setAdults(Number(e.target.value)), className: "w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" })] }), _jsxs("div", { children: [_jsx("label", { className: "text-xs text-gray-400 block mb-1", children: "Tr\u1EBB em" }), _jsx("input", { type: "number", value: children, onChange: e => setChildren(Number(e.target.value)), className: "w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" })] }), _jsxs("div", { children: [_jsx("label", { className: "text-xs text-gray-400 block mb-1", children: "Gi\u1EA3m tr\u1EBB em (%)" }), _jsx("input", { type: "number", value: discount, onChange: e => setDiscount(Number(e.target.value)), className: "w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" })] })] })] }), _jsxs("div", { className: "bg-blue-600 rounded-2xl p-6 text-white shadow-lg shadow-blue-200", children: [_jsxs("div", { className: "flex justify-between items-start mb-8", children: [_jsxs("div", { children: [_jsx("p", { className: "text-blue-100 text-sm", children: "T\u1ED5ng chi ph\u00ED chuy\u1EBFn \u0111i" }), _jsxs("h2", { className: "text-3xl font-bold mt-1", children: [totals.total.toLocaleString(), " VND"] })] }), _jsx(Wallet, { className: "w-8 h-8 opacity-20" })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4 border-t border-blue-500 pt-6", children: [_jsxs("div", { children: [_jsx("p", { className: "text-blue-100 text-xs uppercase tracking-wider font-semibold", children: "M\u1ED7i ng\u01B0\u1EDDi l\u1EDBn" }), _jsxs("p", { className: "text-xl font-bold", children: [totals.adultPrice.toLocaleString(), "\u0111"] })] }), _jsxs("div", { children: [_jsxs("p", { className: "text-blue-100 text-xs uppercase tracking-wider font-semibold", children: ["M\u1ED7i tr\u1EBB em (-", discount, "%)"] }), _jsxs("p", { className: "text-xl font-bold", children: [totals.childPrice.toLocaleString(), "\u0111"] })] })] })] }), _jsxs("div", { className: "flex items-center gap-2 text-gray-400 text-xs px-2", children: [_jsx(Info, { className: "w-4 h-4" }), _jsx("p", { children: "Chi ph\u00ED \u0111\u01B0\u1EE3c t\u1EF1 \u0111\u1ED9ng t\u00EDnh to\u00E1n d\u1EF1a tr\u00EAn h\u00F3a \u0111\u01A1n c\u1EE7a c\u00E1c ch\u1EB7ng." })] })] }));
|
||||
};
|
||||
exports.ExpenseManager = ExpenseManager;
|
||||
//# sourceMappingURL=ExpenseManager.js.map
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"ExpenseManager.js","sourceRoot":"","sources":["../ExpenseManager.tsx"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"ExpenseManager.js","sourceRoot":"","sources":["../ExpenseManager.tsx"],"names":[],"mappings":"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"}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
export declare const ExploreMap: ({ onBack, onLogout, user, onViewTour }: {
|
||||
onBack: () => void;
|
||||
onLogout?: () => void;
|
||||
user?: any;
|
||||
onViewTour: (id: string) => void;
|
||||
}) => React.JSX.Element;
|
||||
Vendored
+99
@@ -0,0 +1,99 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, useMap, useMapEvents } from 'react-leaflet';
|
||||
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||
const MarkerClusterGroup = _MarkerClusterGroup.default || _MarkerClusterGroup;
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { useTourStore } from './useTourStore.js';
|
||||
import { X, Navigation, LogOut, Settings } from 'lucide-react';
|
||||
import { UserManagementModal } from './UserManagementModal.js';
|
||||
import { CreateTourModal } from './CreateTourModal.js';
|
||||
const DefaultIcon = L.icon({
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
});
|
||||
L.Marker.prototype.options.icon = DefaultIcon;
|
||||
function RecenterMap({ position }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
map.setView(position, map.getZoom());
|
||||
}, [position, map]);
|
||||
return null;
|
||||
}
|
||||
function MapTracker() {
|
||||
const setMapCenter = useTourStore(state => state.setMapCenter);
|
||||
useMapEvents({
|
||||
moveend: (e) => {
|
||||
const map = e.target;
|
||||
const center = map.getCenter();
|
||||
const zoom = map.getZoom();
|
||||
const coords = [center.lat, center.lng];
|
||||
setMapCenter(coords);
|
||||
localStorage.setItem('map_view_state', JSON.stringify({ center: coords, zoom }));
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
export const ExploreMap = ({ onBack, onLogout, user, onViewTour }) => {
|
||||
const { publicTours, fetchPublicTours, fetchTour, setMapCenter } = useTourStore();
|
||||
const [initialViewState] = useState(() => {
|
||||
const saved = localStorage.getItem('map_view_state');
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
}
|
||||
catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const [userPos, setUserPos] = useState(initialViewState?.center || [10.7769, 106.7009]);
|
||||
const [mapZoom] = useState(initialViewState?.zoom || 13);
|
||||
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
fetchPublicTours();
|
||||
if (!initialViewState) {
|
||||
navigator.geolocation.getCurrentPosition((pos) => {
|
||||
const posArray = [pos.coords.latitude, pos.coords.longitude];
|
||||
setUserPos(posArray);
|
||||
setMapCenter(posArray);
|
||||
}, () => console.log("Không thể lấy vị trí người dùng"));
|
||||
}
|
||||
else {
|
||||
setMapCenter(initialViewState.center);
|
||||
}
|
||||
}, []);
|
||||
return (_jsxs("div", { className: "h-screen w-full relative", children: [_jsx("button", { onClick: onBack, className: "absolute top-6 left-6 z-[1000] bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all", children: _jsx(X, { className: "w-6 h-6 text-gray-800" }) }), onLogout && (_jsxs("button", { onClick: onLogout, className: "absolute top-6 right-6 z-[1000] bg-white px-4 py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center gap-2 font-bold text-gray-700", children: [_jsx(LogOut, { className: "w-5 h-5" }), _jsx("span", { className: "hidden sm:inline", children: "\u0110\u0103ng xu\u1EA5t" })] })), user?.isAdmin && (_jsxs("button", { onClick: () => setIsAdminModalOpen(true), className: "absolute top-6 right-40 z-[1000] bg-blue-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center gap-2 font-bold", children: [_jsx(Settings, { className: "w-5 h-5" }), _jsx("span", { className: "hidden sm:inline", children: "Qu\u1EA3n l\u00FD h\u1EC7 th\u1ED1ng" })] })), user && (_jsxs("button", { onClick: () => setIsCreateModalOpen(true), className: "absolute top-6 right-80 z-[1000] bg-green-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center gap-2 font-bold", children: [_jsx(Navigation, { className: "w-5 h-5" }), _jsx("span", { className: "hidden sm:inline", children: "T\u1EA1o Tour m\u1EDBi" })] })), _jsx("div", { className: "absolute top-6 left-20 z-[1000] bg-white/90 backdrop-blur-md px-6 py-3 rounded-2xl shadow-xl border border-white/20 hidden sm:block", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Navigation, { className: "w-4 h-4 text-blue-600" }), _jsx("span", { className: "font-bold text-gray-800", children: "\u0110ang kh\u00E1m ph\u00E1 khu v\u1EF1c c\u1EE7a b\u1EA1n" })] }) }), _jsxs(MapContainer, { center: userPos, zoom: mapZoom, className: "h-full w-full", preferCanvas: true, children: [_jsx(TileLayer, { url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", attribution: '\u00A9 OpenStreetMap contributors' }), _jsx(MapTracker, {}), _jsx(RecenterMap, { position: userPos }), _jsx(MarkerClusterGroup, { chunkedLoading: true, children: publicTours.map((tour) => {
|
||||
const startLoc = tour.legs?.[0]?.locations?.[0];
|
||||
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
|
||||
const markerPos = startLoc
|
||||
? [startLoc.latitude, startLoc.longitude]
|
||||
: userPos;
|
||||
return (_jsx(React.Fragment, { children: _jsx(Marker, { position: markerPos, eventHandlers: {
|
||||
click: () => onViewTour(tour.id)
|
||||
}, icon: L.divIcon({
|
||||
className: 'custom-bubble',
|
||||
html: `
|
||||
<div class="relative group">
|
||||
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
|
||||
<img src="${tourImage}" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
|
||||
S
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [48, 48],
|
||||
iconAnchor: [24, 24]
|
||||
}) }) }, tour.id));
|
||||
}) })] }), _jsx(UserManagementModal, { isOpen: isAdminModalOpen, onClose: () => setIsAdminModalOpen(false) }), _jsx(CreateTourModal, { isOpen: isCreateModalOpen, onClose: () => setIsCreateModalOpen(false), onSuccess: (tour) => {
|
||||
fetchTour(tour.id);
|
||||
onViewTour(tour.id);
|
||||
} })] }));
|
||||
};
|
||||
//# sourceMappingURL=ExploreMap.js.map
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+4
-1
@@ -1,2 +1,5 @@
|
||||
import React from 'react';
|
||||
export declare const ItineraryTimeline: () => React.JSX.Element;
|
||||
export declare const ItineraryTimeline: ({ onAddLocation, onEditLocation }: {
|
||||
onAddLocation?: (legId: string) => void;
|
||||
onEditLocation?: (location: any) => void;
|
||||
}) => React.JSX.Element;
|
||||
|
||||
Vendored
+107
-15
@@ -1,23 +1,115 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ItineraryTimeline = void 0;
|
||||
const jsx_runtime_1 = require("react/jsx-runtime");
|
||||
const date_fns_1 = require("date-fns");
|
||||
const lucide_react_1 = require("lucide-react");
|
||||
const useTourStore_1 = require("./useTourStore");
|
||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import { useState } from 'react';
|
||||
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
||||
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List } from 'lucide-react';
|
||||
import { useTourStore } from './useTourStore.js';
|
||||
import { ConfirmModal } from './ConfirmModal.js';
|
||||
const TimeVariance = ({ planned, actual }) => {
|
||||
if (!actual)
|
||||
return null;
|
||||
const diff = (0, date_fns_1.differenceInMinutes)((0, date_fns_1.parseISO)(actual), (0, date_fns_1.parseISO)(planned));
|
||||
const diff = differenceInMinutes(parseISO(actual), parseISO(planned));
|
||||
const isLate = diff > 0;
|
||||
return ((0, jsx_runtime_1.jsxs)("div", { className: `flex items-center text-xs font-medium mt-1 ${isLate ? 'text-red-500' : 'text-green-600'}`, children: [isLate ? (0, jsx_runtime_1.jsx)(lucide_react_1.AlertCircle, { className: "w-3 h-3 mr-1" }) : (0, jsx_runtime_1.jsx)(lucide_react_1.CheckCircle2, { className: "w-3 h-3 mr-1" }), (0, jsx_runtime_1.jsx)("span", { children: isLate ? `Trễ ${diff} phút` : diff === 0 ? 'Đúng giờ' : `Sớm ${Math.abs(diff)} phút` })] }));
|
||||
return (_jsxs("div", { className: `flex items-center text-xs font-medium mt-1 ${isLate ? 'text-red-500' : 'text-green-600'}`, children: [isLate ? _jsx(AlertCircle, { className: "w-3 h-3 mr-1" }) : _jsx(CheckCircle2, { className: "w-3 h-3 mr-1" }), _jsx("span", { children: isLate ? `Trễ ${diff} phút` : diff === 0 ? 'Đúng giờ' : `Sớm ${Math.abs(diff)} phút` })] }));
|
||||
};
|
||||
const ItineraryTimeline = () => {
|
||||
const { legs } = (0, useTourStore_1.useTourStore)();
|
||||
const toggleComplete = async (placeId) => {
|
||||
console.log("Toggle status for place:", placeId);
|
||||
const calculateDistance = (lat1, lon1, lat2, lon2) => {
|
||||
const p = 0.017453292519943295;
|
||||
const c = Math.cos;
|
||||
const a = 0.5 - c((lat2 - lat1) * p) / 2 +
|
||||
c(lat1 * p) * c(lat2 * p) *
|
||||
(1 - c((lon2 - lon1) * p)) / 2;
|
||||
return 12742 * Math.asin(Math.sqrt(a));
|
||||
};
|
||||
const formatTravelTime = (minutes) => {
|
||||
if (minutes < 60)
|
||||
return `${minutes} phút`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours} giờ ${mins} phút` : `${hours} giờ`;
|
||||
};
|
||||
export const ItineraryTimeline = ({ onAddLocation, onEditLocation }) => {
|
||||
const { currentTour, legs, optimizeRouting, userRole, addLeg, updateLeg, deleteLeg, initializeLegs, deleteLocation } = useTourStore();
|
||||
const [confirmState, setConfirmState] = useState({ open: false });
|
||||
const toggleComplete = async (locationId) => {
|
||||
console.log("Toggle status for location:", locationId);
|
||||
};
|
||||
return ((0, jsx_runtime_1.jsxs)("div", { className: "max-w-2xl mx-auto p-4 sm:p-6 bg-gray-50 min-h-screen", children: [(0, jsx_runtime_1.jsx)("h2", { className: "text-2xl font-bold text-gray-800 mb-8 px-2", children: "L\u1ED9 tr\u00ECnh chuy\u1EBFn \u0111i" }), (0, jsx_runtime_1.jsx)("div", { className: "space-y-8", children: legs.map((leg, legIdx) => ((0, jsx_runtime_1.jsxs)("div", { className: "relative", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center mb-4 px-2", children: [(0, jsx_runtime_1.jsxs)("div", { className: "bg-blue-600 text-white text-sm font-bold px-3 py-1 rounded-full shadow-sm", children: ["Ch\u1EB7ng ", leg.sequenceNumber] }), (0, jsx_runtime_1.jsx)("div", { className: "ml-4 h-[1px] flex-1 bg-gray-200" })] }), (0, jsx_runtime_1.jsx)("div", { className: "absolute left-6 top-10 bottom-0 w-0.5 bg-gray-200 -z-0" }), (0, jsx_runtime_1.jsx)("div", { className: "space-y-6 ml-2", children: leg.places.map((place) => ((0, jsx_runtime_1.jsxs)("div", { className: "relative flex group", children: [(0, jsx_runtime_1.jsx)("div", { className: "z-10 mt-1.5 mr-4", children: (0, jsx_runtime_1.jsx)("button", { onClick: () => toggleComplete(place.id), className: `transition-colors duration-200 ${place.isCompleted ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`, children: place.isCompleted ? ((0, jsx_runtime_1.jsx)(lucide_react_1.CheckCircle2, { className: "w-8 h-8 bg-white rounded-full" })) : ((0, jsx_runtime_1.jsx)(lucide_react_1.Circle, { className: "w-8 h-8 bg-white rounded-full fill-white" })) }) }), (0, jsx_runtime_1.jsxs)("div", { className: `flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${place.isCompleted ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'}`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex justify-between items-start", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("h3", { className: `font-semibold text-lg ${place.isCompleted ? 'text-gray-500 line-through' : 'text-gray-800'}`, children: place.name }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center text-sm text-gray-500 mt-1", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.MapPin, { className: "w-3 h-3 mr-1" }), (0, jsx_runtime_1.jsx)("span", { className: "truncate max-w-[200px] sm:max-w-md", children: place.address })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "text-right flex flex-col items-end", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center text-sm font-medium text-blue-600", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Clock, { className: "w-3 h-3 mr-1" }), (0, date_fns_1.format)((0, date_fns_1.parseISO)(place.arrivalTime), 'HH:mm')] }), place.isCompleted && place.completedAt && ((0, jsx_runtime_1.jsxs)("div", { className: "text-[10px] text-gray-400 mt-1 italic", children: ["Th\u1EF1c t\u1EBF: ", (0, date_fns_1.format)((0, date_fns_1.parseISO)(place.completedAt), 'HH:mm')] }))] })] }), (0, jsx_runtime_1.jsx)(TimeVariance, { planned: place.arrivalTime, actual: place.completedAt })] })] }, place.id))) }), leg.notes && ((0, jsx_runtime_1.jsxs)("p", { className: "ml-14 mt-4 text-sm text-gray-400 italic", children: ["* ", leg.notes] }))] }, leg.id))) })] }));
|
||||
const handleAddLeg = async () => {
|
||||
const note = window.prompt("Nhập ghi chú cho chặng mới:", `Chặng ${legs.length + 1}`);
|
||||
if (note && currentTour) {
|
||||
await addLeg(currentTour.id, { note });
|
||||
}
|
||||
};
|
||||
const handleDeclareLegs = async () => {
|
||||
const countStr = window.prompt("Chuyến đi này bạn muốn chia làm bao nhiêu chặng?", "3");
|
||||
const count = parseInt(countStr || "0");
|
||||
if (count > 0 && currentTour) {
|
||||
await initializeLegs(currentTour.id, count);
|
||||
}
|
||||
};
|
||||
const handleEditLeg = async (leg) => {
|
||||
const note = window.prompt("Sửa ghi chú chặng:", leg.note || "");
|
||||
if (note !== null) {
|
||||
await updateLeg(leg.id, { note });
|
||||
}
|
||||
};
|
||||
const handleDeleteLeg = async (legId) => {
|
||||
setConfirmState({
|
||||
open: true,
|
||||
title: 'Xóa chặng',
|
||||
message: 'Bạn có chắc chắn muốn xóa chặng này?',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await deleteLeg(legId);
|
||||
}
|
||||
catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
finally {
|
||||
setConfirmState({ open: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
const handleDeleteLocation = async (id) => {
|
||||
setConfirmState({
|
||||
open: true,
|
||||
title: 'Xóa địa điểm',
|
||||
message: 'Bạn có chắc chắn muốn xóa địa điểm này?',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await deleteLocation(id);
|
||||
}
|
||||
catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
finally {
|
||||
setConfirmState({ open: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
return (_jsxs("div", { className: "max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen", children: [_jsxs("div", { className: "px-2 pt-4", children: [legs.length === 0 ? (_jsxs("div", { className: "text-center py-20 bg-white rounded-3xl border-2 border-dashed border-gray-200", children: [_jsx(List, { className: "w-12 h-12 text-gray-300 mx-auto mb-4" }), _jsx("p", { className: "text-gray-500 font-medium", children: "Ch\u01B0a c\u00F3 ch\u1EB7ng n\u00E0o trong l\u1ED9 tr\u00ECnh." })] })) : (legs.map((leg, legIdx) => {
|
||||
const totalDwellMinutes = leg.locations.reduce((acc, loc) => {
|
||||
if (loc.plannedStart && loc.plannedEnd) {
|
||||
return acc + differenceInMinutes(parseISO(loc.plannedEnd), parseISO(loc.plannedStart));
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
const prevLegLastLoc = legIdx > 0 ? legs[legIdx - 1]?.locations.slice(-1)[0] : null;
|
||||
return (_jsxs("div", { className: "relative mb-12 animate-in fade-in slide-in-from-bottom-4 duration-300", children: [_jsxs("div", { className: "sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2", children: [_jsxs("div", { className: "font-black text-blue-600 text-xl truncate flex-1 flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm", children: leg.sequence }), leg.note || `Chi tiết Chặng ${leg.sequence}`] }), prevLegLastLoc && (_jsxs("div", { className: "flex items-center gap-1 text-[10px] text-gray-400 uppercase tracking-widest ml-10", children: [_jsx(Navigation, { className: "w-2.5 h-2.5 rotate-90" }), " Ti\u1EBFp n\u1ED1i t\u1EEB ", prevLegLastLoc.name] }))] }), _jsxs("div", { className: "flex items-center gap-2 ml-4", children: [['OWNER', 'MANAGER'].includes(userRole || '') && (_jsxs(_Fragment, { children: [_jsx("button", { onClick: () => onAddLocation?.(leg.id), className: "p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all", title: "Th\u00EAm \u0111\u1ECBa \u0111i\u1EC3m v\u00E0o ch\u1EB7ng n\u00E0y", children: _jsx(Plus, { className: "w-4 h-4" }) }), _jsx("button", { onClick: () => handleEditLeg(leg), className: "p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all", children: _jsx(Edit2, { className: "w-4 h-4" }) }), _jsx("button", { onClick: () => handleDeleteLeg(leg.id), className: "p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all", children: _jsx(Trash2, { className: "w-4 h-4" }) })] })), leg.totalDistance !== undefined && (_jsxs("div", { className: "hidden sm:flex items-center gap-2", children: [_jsxs("div", { className: "text-xs font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded-lg border border-blue-100", children: [leg.totalDistance, " km"] }), _jsxs("div", { className: "text-xs font-bold text-gray-500 bg-gray-50 px-2 py-1 rounded-lg border border-gray-100 flex items-center gap-1", children: [_jsx(Clock, { className: "w-3 h-3" }), "~ ", formatTravelTime(Math.round((leg.totalDistance / 35) * 60))] })] })), totalDwellMinutes > 0 && (_jsxs("div", { className: "hidden md:flex text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 items-center gap-1", children: [_jsx(Clock, { className: "w-3 h-3" }), "D\u1EEBng: ", formatTravelTime(totalDwellMinutes)] }))] }), ['OWNER', 'MANAGER'].includes(userRole || '') && legs.length > 0 && (_jsxs("button", { onClick: () => optimizeRouting(leg.id), className: "ml-auto flex items-center gap-1.5 text-xs font-bold text-indigo-600 hover:text-indigo-700 bg-indigo-50 hover:bg-indigo-100 px-3 py-1.5 rounded-xl transition-all", children: [_jsx(Zap, { className: "w-3 h-3" }), "T\u1ED1i \u01B0u"] }))] }), _jsx("div", { className: `absolute left-6 top-16 ${legIdx === legs.length - 1 ? 'bottom-0' : 'bottom-[-3.5rem]'} w-0.5 bg-blue-100 -z-0` }), _jsx("div", { className: "ml-2", children: leg.locations.map((location, idx) => {
|
||||
const nextLocation = leg.locations[idx + 1] || legs[legIdx + 1]?.locations[0];
|
||||
const distanceToNext = nextLocation
|
||||
? calculateDistance(location.latitude, location.longitude, nextLocation.latitude, nextLocation.longitude)
|
||||
: null;
|
||||
const averageSpeed = 35;
|
||||
const travelTimeMinutes = distanceToNext ? Math.round((distanceToNext / averageSpeed) * 60) : null;
|
||||
const dwellMinutes = (location.plannedStart && location.plannedEnd)
|
||||
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
||||
: null;
|
||||
const locationExpense = leg.expenses?.find((e) => e.locationId === location.id);
|
||||
const isStartPoint = legs[0]?.id === leg.id && idx === 0;
|
||||
const isEndPoint = legs[legs.length - 1]?.id === leg.id && idx === leg.locations.length - 1;
|
||||
return (_jsxs("div", { children: [_jsxs("div", { className: "relative flex group mb-6", children: [_jsx("div", { className: "z-10 mt-1.5 mr-4", children: _jsx("button", { onClick: () => toggleComplete(location.id), className: `transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`, children: location.status === 'COMPLETED' ? (_jsx(CheckCircle2, { className: "w-8 h-8 bg-white rounded-full" })) : (_jsx(Circle, { className: "w-8 h-8 bg-white rounded-full fill-white" })) }) }), _jsxs("div", { className: `flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${location.status === 'COMPLETED' ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'}`, children: [_jsxs("div", { className: "flex justify-between items-start", children: [_jsxs("div", { children: [isStartPoint && (_jsx("span", { className: "inline-block px-2 py-0.5 bg-green-100 text-green-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider", children: "\u0110i\u1EC3m b\u1EAFt \u0111\u1EA7u" })), isEndPoint && (_jsx("span", { className: "inline-block px-2 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider", children: "\u0110i\u1EC3m k\u1EBFt th\u00FAc" })), _jsx("h3", { className: `font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`, children: location.name }), _jsxs("div", { className: "flex items-center text-sm text-gray-500 mt-1", children: [_jsx(MapPin, { className: "w-3 h-3 mr-1" }), _jsx("span", { className: "truncate max-w-[200px] sm:max-w-md", children: location.address })] }), location.note && (_jsx("div", { className: "mt-2 text-xs text-gray-600 bg-gray-50 p-2 rounded-lg border border-gray-100 italic", children: location.note })), dwellMinutes !== null && (_jsxs("div", { className: "flex items-center text-xs text-amber-600 font-medium mt-1", children: [_jsx(Clock, { className: "w-3 h-3 mr-1" }), _jsxs("span", { children: ["Th\u1EDDi gian d\u1EEBng: ", formatTravelTime(dwellMinutes)] })] })), locationExpense && (_jsxs("div", { className: "mt-2 text-xs text-indigo-600 bg-indigo-50/80 p-2 rounded-lg border border-indigo-100 space-y-1", children: [_jsxs("div", { className: "flex items-center gap-1 font-bold", children: [_jsx(Zap, { className: "w-3 h-3" }), _jsxs("span", { children: ["Chi ph\u00ED: ", Number(locationExpense.amount).toLocaleString(), "\u0111 (", locationExpense.category, ")"] })] }), locationExpense.description && (_jsxs("div", { className: "text-[10px] text-gray-600", children: ["D\u1ECBch v\u1EE5: ", locationExpense.description] })), locationExpense.note && (_jsx("div", { className: "text-[10px] text-gray-500 italic", children: locationExpense.note })), locationExpense.paidBy && (_jsxs("div", { className: "text-[10px] font-semibold text-indigo-700", children: ["\u0110\u00E3 thanh to\u00E1n: ", locationExpense.paidBy.name] }))] }))] }), _jsxs("div", { className: "text-right flex flex-col items-end", children: [_jsxs("div", { className: "flex items-center text-sm font-medium text-blue-600", children: [_jsx(Clock, { className: "w-3 h-3 mr-1" }), location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'] }), location.status === 'COMPLETED' && location.actualStart && (_jsxs("div", { className: "text-[10px] text-gray-400 mt-1 italic", children: ["Th\u1EF1c t\u1EBF: ", format(parseISO(location.actualStart), 'HH:mm')] })), ['OWNER', 'MANAGER'].includes(userRole || '') && !isStartPoint && !isEndPoint && (_jsxs("div", { className: "flex gap-1 mt-2", children: [_jsx("button", { onClick: () => onEditLocation?.(location), className: "p-1 text-gray-400 hover:text-blue-600 transition-colors", children: _jsx(Edit2, { className: "w-3.5 h-3.5" }) }), _jsx("button", { onClick: () => handleDeleteLocation(location.id), className: "p-1 text-gray-400 hover:text-red-600 transition-colors", children: _jsx(Trash2, { className: "w-3.5 h-3.5" }) })] }))] })] }), _jsx(TimeVariance, { planned: location.plannedStart || '', actual: location.actualStart || null })] })] }), distanceToNext !== null && travelTimeMinutes !== null && (_jsxs("div", { className: "ml-4 -mt-4 mb-2 flex items-center gap-3", children: [_jsx("div", { className: "w-8 flex justify-center", children: _jsx(Navigation, { className: "w-3 h-3 text-blue-400 rotate-180" }) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("span", { className: "text-[10px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100", children: [distanceToNext.toFixed(2), " km"] }), _jsxs("span", { className: "text-[10px] font-bold text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full border border-gray-100 flex items-center gap-1", children: [_jsx(Clock, { className: "w-2.5 h-2.5" }), "~ ", formatTravelTime(travelTimeMinutes)] })] })] }))] }, location.id));
|
||||
}) })] }, leg.id));
|
||||
})), ['OWNER', 'MANAGER'].includes(userRole || '') && (_jsxs("div", { className: "flex flex-col gap-3 pb-20 mt-8", children: [_jsxs("button", { onClick: handleDeclareLegs, className: "w-full py-4 rounded-2xl bg-white text-blue-600 border-2 border-dashed border-blue-200 hover:bg-blue-50 transition-all flex items-center justify-center gap-2 text-sm font-bold", children: [_jsx(List, { className: "w-5 h-5" }), legs.length > 0 ? "Khai báo lại số lượng chặng" : "Bắt đầu bằng việc khai báo số chặng"] }), _jsxs("button", { onClick: handleAddLeg, className: "w-full py-4 rounded-2xl bg-blue-600 text-white shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all flex items-center justify-center gap-2 text-sm font-bold", children: [_jsx(Plus, { className: "w-5 h-5" }), " Th\u00EAm ch\u1EB7ng l\u1EBB v\u00E0o cu\u1ED1i"] })] }))] }), _jsx(ConfirmModal, { isOpen: confirmState.open, title: confirmState.title, message: confirmState.message, onConfirm: () => confirmState.onConfirm?.(), onCancel: () => setConfirmState({ open: false }) })] }));
|
||||
};
|
||||
exports.ItineraryTimeline = ItineraryTimeline;
|
||||
//# sourceMappingURL=ItineraryTimeline.js.map
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+4
@@ -1,6 +1,10 @@
|
||||
import React from 'react';
|
||||
interface LandingPageProps {
|
||||
onContinue?: () => void;
|
||||
onGoToSignup?: () => void;
|
||||
onGoToMap?: () => void;
|
||||
onLoginSuccess?: (user: any) => void;
|
||||
isInitialSetup?: boolean;
|
||||
}
|
||||
export declare const LandingPage: React.FC<LandingPageProps>;
|
||||
export {};
|
||||
|
||||
Vendored
+18
-11
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"LandingPage.js","sourceRoot":"","sources":["../LandingPage.tsx"],"names":[],"mappings":";;;;AAAA,iCAAwC;AACxC,+CAAkE;AAClE,6CAA0C;AAMnC,MAAM,WAAW,GAA+B,CAAC,EAAE,UAAU,EAAE,EAAE,EAAE;IACxE,MAAM,CAAC,gBAAgB,EAAE,mBAAmB,CAAC,GAAG,IAAA,gBAAQ,EAAC,KAAK,CAAC,CAAC;IAEhE,OAAO,CACL,iCAAK,SAAS,EAAC,8EAA8E,aAE3F,iCAAK,SAAS,EAAC,+CAA+C,aAC5D,gCACE,GAAG,EAAC,+FAA+F,EACnG,GAAG,EAAC,2BAA2B,EAC/B,SAAS,EAAC,wDAAwD,GAClE,EAEF,gCAAK,SAAS,EAAC,mEAAmE,GAAG,EAErF,iCAAK,SAAS,EAAC,4CAA4C,aACzD,iCAAK,SAAS,EAAC,8BAA8B,aAC3C,gCAAK,SAAS,EAAC,8CAA8C,YAC3D,uBAAC,sBAAO,IAAC,SAAS,EAAC,oBAAoB,GAAG,GACtC,EACN,iCAAM,SAAS,EAAC,gDAAgD,+BAAsB,IAClF,EACN,+BAAI,SAAS,EAAC,2CAA2C,4FAEpD,EACL,8BAAG,SAAS,EAAC,gDAAgD,uKAEzD,IACA,IACF,EAGN,gCAAK,SAAS,EAAC,+EAA+E,YAC5F,iCAAK,SAAS,EAAC,4BAA4B,aACzC,iCAAK,SAAS,EAAC,WAAW,aACxB,+BAAI,SAAS,EAAC,iEAAiE,mHAE1E,EACL,8BAAG,SAAS,EAAC,uCAAuC,+PAEhD,IACA,EAEN,iCAAK,SAAS,EAAC,qBAAqB,aAElC,oCACE,OAAO,EAAE,GAAG,EAAE,CAAC,mBAAmB,CAAC,IAAI,CAAC,EACxC,SAAS,EAAC,gLAAgL,aAE1L,uBAAC,oBAAK,IAAC,SAAS,EAAC,wDAAwD,GAAG,qCAErE,EAGT,oCACE,OAAO,EAAE,UAAU,EACnB,SAAS,EAAC,yKAAyK,sDAGnL,uBAAC,yBAAU,IAAC,SAAS,EAAC,SAAS,GAAG,IAC3B,IACL,EAEN,iCAAK,SAAS,EAAC,8EAA8E,aAC3F,gCAAK,SAAS,EAAC,iBAAiB,YAC7B,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CACrB,gCAAa,SAAS,EAAC,8CAA8C,EAAC,GAAG,EAAE,iCAAiC,CAAC,GAAC,EAAE,EAAE,EAAE,GAAG,EAAC,MAAM,IAApH,CAAC,CAAsH,CAClI,CAAC,GACE,EACN,8BAAG,SAAS,EAAC,aAAa,iFAA4C,IAClE,IACF,GACF,EAGN,uBAAC,uBAAU,IAAC,MAAM,EAAE,gBAAgB,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,mBAAmB,CAAC,KAAK,CAAC,GAAI,IAC/E,CACP,CAAC;AACJ,CAAC,CAAC;AA9EW,QAAA,WAAW,eA8EtB"}
|
||||
{"version":3,"file":"LandingPage.js","sourceRoot":"","sources":["../LandingPage.tsx"],"names":[],"mappings":"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"}
|
||||
Vendored
+2
@@ -2,6 +2,8 @@ import React from 'react';
|
||||
interface LoginModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSwitchToSignup?: () => void;
|
||||
onLoginSuccess?: (user: any) => void;
|
||||
}
|
||||
export declare const LoginModal: React.FC<LoginModalProps>;
|
||||
export {};
|
||||
|
||||
Vendored
+38
-8
@@ -1,12 +1,42 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.LoginModal = void 0;
|
||||
const jsx_runtime_1 = require("react/jsx-runtime");
|
||||
const lucide_react_1 = require("lucide-react");
|
||||
const LoginModal = ({ isOpen, onClose }) => {
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState } from 'react';
|
||||
import { X, Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
|
||||
export const LoginModal = ({ isOpen, onClose, onSwitchToSignup, onLoginSuccess }) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
if (!isOpen)
|
||||
return null;
|
||||
return ((0, jsx_runtime_1.jsxs)("div", { className: "fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6", children: [(0, jsx_runtime_1.jsx)("div", { className: "absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-300", onClick: onClose }), (0, jsx_runtime_1.jsx)("div", { className: "relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300", children: (0, jsx_runtime_1.jsxs)("div", { className: "p-8 sm:p-10", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex justify-between items-start mb-8", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("h2", { className: "text-3xl font-bold text-gray-900", children: "\u0110\u0103ng nh\u1EADp" }), (0, jsx_runtime_1.jsx)("p", { className: "text-gray-500 mt-2", children: "Ch\u00E0o m\u1EEBng b\u1EA1n quay tr\u1EDF l\u1EA1i!" })] }), (0, jsx_runtime_1.jsx)("button", { onClick: onClose, className: "p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400 hover:text-gray-600", children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "w-6 h-6" }) })] }), (0, jsx_runtime_1.jsxs)("form", { className: "space-y-6", onSubmit: (e) => e.preventDefault(), children: [(0, jsx_runtime_1.jsxs)("div", { className: "space-y-2", children: [(0, jsx_runtime_1.jsx)("label", { className: "text-sm font-semibold text-gray-700 ml-1", children: "Email" }), (0, jsx_runtime_1.jsxs)("div", { className: "relative group", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Mail, { className: "absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" }), (0, jsx_runtime_1.jsx)("input", { type: "email", placeholder: "name@example.com", className: "w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all" })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "space-y-2", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex justify-between items-center px-1", children: [(0, jsx_runtime_1.jsx)("label", { className: "text-sm font-semibold text-gray-700", children: "M\u1EADt kh\u1EA9u" }), (0, jsx_runtime_1.jsx)("button", { className: "text-xs font-bold text-blue-600 hover:text-blue-700", children: "Qu\u00EAn m\u1EADt kh\u1EA9u?" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "relative group", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Lock, { className: "absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" }), (0, jsx_runtime_1.jsx)("input", { type: "password", placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022", className: "w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all" })] })] }), (0, jsx_runtime_1.jsxs)("button", { type: "submit", className: "w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-4 rounded-2xl shadow-lg shadow-blue-200 transition-all active:scale-[0.98]", children: ["\u0110\u0103ng nh\u1EADp", (0, jsx_runtime_1.jsx)(lucide_react_1.ArrowRight, { className: "w-5 h-5" })] })] }), (0, jsx_runtime_1.jsx)("div", { className: "mt-10 pt-8 border-t border-gray-100 text-center", children: (0, jsx_runtime_1.jsxs)("p", { className: "text-gray-500", children: ["Ch\u01B0a c\u00F3 t\u00E0i kho\u1EA3n?", ' ', (0, jsx_runtime_1.jsx)("button", { className: "font-bold text-blue-600 hover:underline", children: "T\u1EA1o t\u00E0i kho\u1EA3n ngay" })] }) })] }) })] }));
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const response = await fetch(`${API_BASE}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Đăng nhập thất bại');
|
||||
}
|
||||
localStorage.setItem('token', data.access_token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
if (onLoginSuccess) {
|
||||
onLoginSuccess(data.user);
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
return (_jsxs("div", { className: "fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-300", onClick: onClose }), _jsx("div", { className: "relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300", children: _jsxs("div", { className: "p-8 sm:p-10", children: [_jsxs("div", { className: "flex justify-between items-start mb-8", children: [_jsxs("div", { children: [_jsx("h2", { className: "text-3xl font-bold text-gray-900", children: "\u0110\u0103ng nh\u1EADp" }), _jsx("p", { className: "text-gray-500 mt-2", children: "Ch\u00E0o m\u1EEBng b\u1EA1n quay tr\u1EDF l\u1EA1i!" })] }), _jsx("button", { onClick: onClose, className: "p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400 hover:text-gray-600", children: _jsx(X, { className: "w-6 h-6" }) })] }), error && (_jsx("div", { className: "mb-6 p-4 bg-red-50 text-red-600 rounded-2xl text-sm font-bold animate-in fade-in slide-in-from-top-1", children: error })), _jsxs("form", { className: "space-y-6", onSubmit: handleSubmit, children: [_jsxs("div", { className: "space-y-2", children: [_jsx("label", { className: "text-sm font-semibold text-gray-700 ml-1", children: "Email" }), _jsxs("div", { className: "relative group", children: [_jsx(Mail, { className: "absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" }), _jsx("input", { type: "email", placeholder: "name@example.com", value: email, onChange: (e) => setEmail(e.target.value), required: true, className: "w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all" })] })] }), _jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex justify-between items-center px-1", children: [_jsx("label", { className: "text-sm font-semibold text-gray-700", children: "M\u1EADt kh\u1EA9u" }), _jsx("button", { className: "text-xs font-bold text-blue-600 hover:text-blue-700", children: "Qu\u00EAn m\u1EADt kh\u1EA9u?" })] }), _jsxs("div", { className: "relative group", children: [_jsx(Lock, { className: "absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" }), _jsx("input", { type: "password", placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022", value: password, onChange: (e) => setPassword(e.target.value), required: true, className: "w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all" })] })] }), _jsxs("button", { type: "submit", disabled: isLoading, className: "w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-4 rounded-2xl shadow-lg shadow-blue-200 transition-all active:scale-[0.98] disabled:opacity-50", children: [isLoading ? 'Đang xử lý...' : 'Đăng nhập', isLoading ? _jsx(Loader2, { className: "w-5 h-5 animate-spin" }) : _jsx(ArrowRight, { className: "w-5 h-5" })] })] }), _jsx("div", { className: "mt-10 pt-8 border-t border-gray-100 text-center", children: _jsxs("p", { className: "text-gray-500", children: ["Ch\u01B0a c\u00F3 t\u00E0i kho\u1EA3n?", ' ', _jsx("button", { onClick: () => { onClose(); onSwitchToSignup?.(); }, className: "font-bold text-blue-600 hover:underline", children: "T\u1EA1o t\u00E0i kho\u1EA3n ngay" })] }) })] }) })] }));
|
||||
};
|
||||
exports.LoginModal = LoginModal;
|
||||
//# sourceMappingURL=LoginModal.js.map
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"LoginModal.js","sourceRoot":"","sources":["../LoginModal.tsx"],"names":[],"mappings":";;;;AACA,+CAAyD;AAOlD,MAAM,UAAU,GAA8B,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;IAC3E,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAC;IAEzB,OAAO,CACL,iCAAK,SAAS,EAAC,gEAAgE,aAE7E,gCACE,SAAS,EAAC,kFAAkF,EAC5F,OAAO,EAAE,OAAO,GAChB,EAGF,gCAAK,SAAS,EAAC,6GAA6G,YAC1H,iCAAK,SAAS,EAAC,aAAa,aAC1B,iCAAK,SAAS,EAAC,uCAAuC,aACpD,4CACE,+BAAI,SAAS,EAAC,kCAAkC,yCAAe,EAC/D,8BAAG,SAAS,EAAC,oBAAoB,qEAAgC,IAC7D,EACN,mCACE,OAAO,EAAE,OAAO,EAChB,SAAS,EAAC,wFAAwF,YAElG,uBAAC,gBAAC,IAAC,SAAS,EAAC,SAAS,GAAG,GAClB,IACL,EAEN,kCAAM,SAAS,EAAC,WAAW,EAAC,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,cAAc,EAAE,aAC7D,iCAAK,SAAS,EAAC,WAAW,aACxB,kCAAO,SAAS,EAAC,0CAA0C,sBAAc,EACzE,iCAAK,SAAS,EAAC,gBAAgB,aAC7B,uBAAC,mBAAI,IAAC,SAAS,EAAC,mHAAmH,GAAG,EACtI,kCACE,IAAI,EAAC,OAAO,EACZ,WAAW,EAAC,kBAAkB,EAC9B,SAAS,EAAC,kJAAkJ,GAC5J,IACE,IACF,EAEN,iCAAK,SAAS,EAAC,WAAW,aACxB,iCAAK,SAAS,EAAC,wCAAwC,aACrD,kCAAO,SAAS,EAAC,qCAAqC,mCAAiB,EACvE,mCAAQ,SAAS,EAAC,qDAAqD,8CAAwB,IAC3F,EACN,iCAAK,SAAS,EAAC,gBAAgB,aAC7B,uBAAC,mBAAI,IAAC,SAAS,EAAC,mHAAmH,GAAG,EACtI,kCACE,IAAI,EAAC,UAAU,EACf,WAAW,EAAC,kDAAU,EACtB,SAAS,EAAC,kJAAkJ,GAC5J,IACE,IACF,EAEN,oCACE,IAAI,EAAC,QAAQ,EACb,SAAS,EAAC,gLAAgL,yCAG1L,uBAAC,yBAAU,IAAC,SAAS,EAAC,SAAS,GAAG,IAC3B,IACJ,EAEP,gCAAK,SAAS,EAAC,iDAAiD,YAC9D,+BAAG,SAAS,EAAC,eAAe,uDACP,GAAG,EACtB,mCAAQ,SAAS,EAAC,yCAAyC,kDAA4B,IACrF,GACA,IACF,GACF,IACF,CACP,CAAC;AACJ,CAAC,CAAC;AA1EW,QAAA,UAAU,cA0ErB"}
|
||||
{"version":3,"file":"LoginModal.js","sourceRoot":"","sources":["../LoginModal.tsx"],"names":[],"mappings":"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"}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
interface SignupPageProps {
|
||||
onBack: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
export declare const SignupPage: React.FC<SignupPageProps>;
|
||||
export {};
|
||||
Vendored
+51
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"SignupPage.js","sourceRoot":"","sources":["../SignupPage.tsx"],"names":[],"mappings":"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"}
|
||||
Vendored
+3
-1
@@ -1,2 +1,4 @@
|
||||
import React from 'react';
|
||||
export declare const TourDetailPage: () => React.JSX.Element;
|
||||
export declare const TourDetailPage: ({ onBack }: {
|
||||
onBack: () => void;
|
||||
}) => React.JSX.Element;
|
||||
|
||||
Vendored
+381
-30
@@ -1,37 +1,388 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.TourDetailPage = void 0;
|
||||
const jsx_runtime_1 = require("react/jsx-runtime");
|
||||
const react_1 = require("react");
|
||||
const ItineraryTimeline_1 = require("./ItineraryTimeline");
|
||||
const ExpenseManager_1 = require("./ExpenseManager");
|
||||
const useTourStore_1 = require("./useTourStore");
|
||||
const lucide_react_1 = require("lucide-react");
|
||||
const TourDetailPage = () => {
|
||||
const [activeTab, setActiveTab] = (0, react_1.useState)('plan');
|
||||
const { currentTour, fetchTour, userRole } = (0, useTourStore_1.useTourStore)();
|
||||
(0, react_1.useEffect)(() => {
|
||||
fetchTour(1);
|
||||
}, [fetchTour]);
|
||||
const tabs = [
|
||||
{ id: 'plan', label: 'Lộ trình', icon: lucide_react_1.Map, visible: ['OWNER', 'EDITOR', 'MEMBER_PLAN_ONLY'].includes(userRole || '') },
|
||||
{ id: 'expense', label: 'Chi phí', icon: lucide_react_1.Wallet, visible: ['OWNER', 'EDITOR'].includes(userRole || '') },
|
||||
{ id: 'photo', label: 'Ảnh', icon: lucide_react_1.Image, visible: true },
|
||||
].filter(t => t.visible);
|
||||
(0, react_1.useEffect)(() => {
|
||||
if (userRole === 'MEMBER_PHOTO_ONLY') {
|
||||
setActiveTab('photo');
|
||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { ItineraryTimeline } from './ItineraryTimeline.js';
|
||||
import { ExpenseManager } from './ExpenseManager.js';
|
||||
import { useTourStore } from './useTourStore.js';
|
||||
import { AddLocationModal } from './AddLocationModal.js';
|
||||
import { AddMemberModal } from './AddMemberModal.js';
|
||||
import { ConfirmModal } from './ConfirmModal.js';
|
||||
import { NotificationModal, useNotificationModal } from './components/NotificationModal.js';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline } from 'react-leaflet';
|
||||
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||
const MarkerClusterGroup = _MarkerClusterGroup.default || _MarkerClusterGroup;
|
||||
import { useMap } from 'react-leaflet';
|
||||
import { Map as MapIcon, Wallet, Image as ImageIcon, Calendar, Users, ChevronLeft, Settings, Quote, Plus, List, Map as MapIconLucide, MapPin, Flag, Clock, Check, X } from 'lucide-react';
|
||||
import L from 'leaflet';
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
});
|
||||
const START_ICON = L.divIcon({
|
||||
className: 'custom-marker-s',
|
||||
html: `<div class="w-6 h-6 bg-blue-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-[10px] font-black text-white">S</div>`,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12]
|
||||
});
|
||||
const END_ICON = L.divIcon({
|
||||
className: 'custom-marker-e',
|
||||
html: `<div class="w-6 h-6 bg-green-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-[10px] font-black text-white">E</div>`,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12]
|
||||
});
|
||||
const VISIT_ICON = L.divIcon({
|
||||
className: 'custom-marker-v',
|
||||
html: `<div class="w-4 h-4 bg-indigo-500 rounded-full border-2 border-white shadow-md"></div>`,
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8]
|
||||
});
|
||||
const MapTourBounds = ({ locations }) => {
|
||||
const map = useMap();
|
||||
const locKey = useMemo(() => JSON.stringify(locations.map(l => [l.latitude, l.longitude])), [locations]);
|
||||
useEffect(() => {
|
||||
if (locations.length > 0) {
|
||||
const bounds = L.latLngBounds(locations.map(l => [l.latitude, l.longitude]));
|
||||
if (locations.length === 1) {
|
||||
map.setView([locations[0].latitude, locations[0].longitude], 15, { animate: true });
|
||||
}
|
||||
else {
|
||||
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
|
||||
}
|
||||
}
|
||||
}, [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 = {
|
||||
title: currentTour?.title || "Hành trình khám phá TP.HCM",
|
||||
date: "20 - 21 Tháng 11, 2023",
|
||||
members: 5,
|
||||
budget: "2.500.000 VND"
|
||||
date: currentTour?.startDate ? `${new Date(currentTour.startDate).toLocaleDateString('vi-VN')} - ${new Date(currentTour.endDate).toLocaleDateString('vi-VN')}` : "Chưa xác định ngày",
|
||||
membersCount: currentTour?.participants?.length || 0,
|
||||
budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND",
|
||||
coverImage: currentTour?.photos?.[0]?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
|
||||
};
|
||||
return ((0, jsx_runtime_1.jsxs)("div", { className: "min-h-screen bg-white", children: [(0, jsx_runtime_1.jsxs)("div", { className: "sticky top-0 z-30 bg-white border-b border-gray-100 px-4 py-3 flex items-center justify-between", children: [(0, jsx_runtime_1.jsx)("button", { className: "p-2 hover:bg-gray-100 rounded-full transition-colors", children: (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronLeft, { className: "w-6 h-6 text-gray-600" }) }), (0, jsx_runtime_1.jsx)("h1", { className: "text-lg font-bold text-gray-800 truncate px-4", children: tourInfo.title }), (0, jsx_runtime_1.jsx)("div", { className: "w-10" }), " "] }), (0, jsx_runtime_1.jsx)("div", { className: "bg-blue-600 text-white p-6 pb-20", children: (0, jsx_runtime_1.jsxs)("div", { className: "max-w-2xl mx-auto space-y-4", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex flex-wrap gap-4 text-sm opacity-90", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Calendar, { className: "w-4 h-4 mr-1.5" }), tourInfo.date] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Users, { className: "w-4 h-4 mr-1.5" }), tourInfo.members, " th\u00E0nh vi\u00EAn"] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-baseline gap-2", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-3xl font-bold", children: tourInfo.budget }), (0, jsx_runtime_1.jsx)("span", { className: "text-blue-100 text-sm", children: "d\u1EF1 ki\u1EBFn" })] })] }) }), (0, jsx_runtime_1.jsxs)("div", { className: "max-w-2xl mx-auto -mt-12 px-4 pb-24", children: [(0, jsx_runtime_1.jsx)("div", { className: "bg-white rounded-2xl shadow-xl border border-gray-100 p-1 flex mb-6", children: tabs.map((tab) => ((0, jsx_runtime_1.jsxs)("button", { onClick: () => setActiveTab(tab.id), className: `flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${activeTab === tab.id
|
||||
return (_jsxs("div", { className: "min-h-screen bg-gray-50 pb-20", children: [_jsxs("div", { className: "sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-gray-100 px-4 py-3 flex items-center justify-between", children: [_jsx("button", { onClick: onBack, className: "p-2 hover:bg-gray-100 rounded-full transition-colors", children: _jsx(ChevronLeft, { className: "w-6 h-6 text-gray-600" }) }), _jsx("h1", { className: "text-lg font-bold text-gray-800 truncate px-4", children: tourInfo.title }), _jsx("div", { className: "w-10" }), " "] }), _jsxs("div", { className: "relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end", children: [_jsx("img", { src: tourInfo.coverImage, className: "absolute inset-0 w-full h-full object-cover opacity-60", alt: "Tour Cover" }), _jsx("div", { className: "absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" }), _jsx("div", { className: "relative z-10 p-6 text-white pt-28 pb-20", children: _jsxs("div", { className: "max-w-2xl mx-auto space-y-4", children: [_jsx("h2", { className: "text-3xl font-black tracking-tight drop-shadow-md", children: tourInfo.title }), _jsxs("div", { className: "mt-3 text-[11px] sm:text-sm font-bold text-white bg-black/40 backdrop-blur-md px-4 py-2 rounded-xl border border-white/10 inline-flex items-center max-w-full", children: [_jsx("span", { className: "text-white/60 mr-1", children: "L\u1ED9 tr\u00ECnh:" }), _jsx("span", { className: "text-blue-300", children: "\u0110i\u1EC3m xu\u1EA5t ph\u00E1t:" }), _jsx("span", { className: "ml-1 text-white banner-location-text", title: startPoint?.name, children: startPoint?.name || '...' }), _jsx("span", { className: "mx-2 text-white/30", children: "-" }), _jsx("span", { className: "text-green-300", children: "\u0110i\u1EC3m k\u1EBFt th\u00FAc:" }), _jsx("span", { className: "ml-1 text-white banner-location-text", title: endPoint?.name, children: endPoint?.name || '...' })] }), _jsxs("div", { className: "flex flex-wrap gap-4 text-sm font-medium opacity-90", children: [_jsxs("div", { className: "flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10", children: [_jsx(Calendar, { className: "w-4 h-4 mr-1.5" }), tourInfo.date] }), _jsxs("div", { className: "flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10", children: [_jsx(Users, { className: "w-4 h-4 mr-1.5" }), tourInfo.membersCount, " th\u00E0nh vi\u00EAn"] })] }), _jsxs("div", { className: "flex items-center gap-2 mt-4", children: [_jsxs("div", { className: "flex flex-wrap gap-2", children: [currentTour?.participants?.slice(0, 5).map((p, i) => (_jsx("button", { onClick: () => {
|
||||
setSelectedMember(p);
|
||||
setIsMemberDetailOpen(true);
|
||||
}, className: "w-10 h-10 rounded-full border-2 border-white bg-blue-100 flex items-center justify-center text-blue-600 font-bold overflow-hidden shadow-lg hover:scale-110 transition-transform", title: p.user?.name || p.userId, children: _jsx("img", { src: `https://i.pravatar.cc/100?u=${p.userId}`, alt: "Avatar" }) }, p.userId || i))), joinRequests.slice(0, 3).map((req) => (_jsxs("div", { className: "relative group", children: [_jsx("div", { className: "w-10 h-10 rounded-full border-2 border-amber-400 bg-amber-50 flex items-center justify-center text-amber-700 font-bold overflow-hidden shadow-lg", children: req.user?.name?.charAt(0) || '?' }), _jsxs("div", { className: "absolute -top-1 -right-1 flex", children: [_jsx("button", { type: "button", disabled: joinRequestActionId === req.id, onClick: async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!currentTour)
|
||||
return;
|
||||
setConfirmState({
|
||||
open: true,
|
||||
title: 'Chấp nhận yêu cầu',
|
||||
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`,
|
||||
onConfirm: async () => {
|
||||
setJoinRequestActionId(req.id);
|
||||
try {
|
||||
await acceptJoinRequest(currentTour.id, req.id);
|
||||
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||
}
|
||||
catch (e) {
|
||||
notificationModal.openModal('Thông báo', e.message || 'Không thể chấp nhận yêu cầu', 'error');
|
||||
}
|
||||
finally {
|
||||
setJoinRequestActionId(null);
|
||||
setConfirmState({ open: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
}, className: "w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50", "aria-label": "Accept", children: "+" }), _jsx("button", { type: "button", disabled: joinRequestActionId === req.id, onClick: async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!currentTour)
|
||||
return;
|
||||
setConfirmState({
|
||||
open: true,
|
||||
title: 'Từ chối yêu cầu',
|
||||
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`,
|
||||
onConfirm: async () => {
|
||||
setJoinRequestActionId(req.id);
|
||||
try {
|
||||
await rejectJoinRequest(currentTour.id, req.id);
|
||||
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||
}
|
||||
catch (e) {
|
||||
notificationModal.openModal('Thông báo', e.message || 'Không thể từ chối yêu cầu', 'error');
|
||||
}
|
||||
finally {
|
||||
setJoinRequestActionId(null);
|
||||
setConfirmState({ open: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
}, className: "w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1", "aria-label": "Reject", children: "x" })] })] }, req.id))), tourInfo.membersCount > 5 && (_jsxs("div", { className: "w-10 h-10 rounded-full border-2 border-white bg-gray-800 flex items-center justify-center text-[10px] font-bold text-white shadow-lg", children: ["+", tourInfo.membersCount - 5] }))] }), _jsx("button", { onClick: () => {
|
||||
if (!currentTour)
|
||||
return;
|
||||
if (canEdit)
|
||||
setIsAddMemberOpen(true);
|
||||
}, disabled: !canEdit, className: `p-2 rounded-full border backdrop-blur-sm transition-all ml-2 ${canEdit ? 'bg-white/10 hover:bg-white/20 border-white/20' : 'bg-white/5 border-white/10 opacity-50'}`, children: _jsx(Plus, { className: "w-4 h-4" }) })] })] }) })] }), _jsx("div", { className: "max-w-2xl mx-auto -mt-10 px-4 relative z-10", children: _jsx("div", { className: `rounded-3xl p-6 shadow-2xl transition-all duration-500 ${hasFinanceAccess ? 'bg-indigo-600 text-white shadow-indigo-200' : 'bg-white text-gray-600 border border-gray-100'}`, children: _jsx("div", { className: "flex justify-between items-center", children: hasFinanceAccess ? (_jsxs(_Fragment, { children: [_jsxs("div", { children: [_jsx("p", { className: "text-indigo-100 text-xs font-black uppercase tracking-widest mb-1", children: "T\u1ED5ng chi ti\u00EAu hi\u1EC7n t\u1EA1i" }), _jsx("h3", { className: "text-3xl font-black", children: tourInfo.budget })] }), _jsx("div", { className: "p-4 bg-white/10 rounded-2xl backdrop-blur-md", children: _jsx(Wallet, { className: "w-8 h-8" }) })] })) : (_jsxs("div", { className: "flex items-start gap-4 py-2", children: [_jsx(Quote, { className: "w-8 h-8 text-blue-500 opacity-30 flex-shrink-0" }), _jsxs("p", { className: "italic text-lg font-medium leading-relaxed", children: ["\"", randomQuote, "\""] })] })) }) }) }), _jsxs("div", { className: "max-w-2xl mx-auto mt-4 px-4 grid grid-cols-1 sm:grid-cols-2 gap-3 animate-in fade-in slide-in-from-top-2 duration-500", children: [startPoint && (_jsxs("div", { className: "bg-white p-4 rounded-2xl border border-blue-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow", children: [_jsx("div", { className: "w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center text-blue-600 shadow-inner", children: _jsx(MapPin, { className: "w-5 h-5" }) }), _jsxs("div", { className: "overflow-hidden", children: [_jsx("p", { className: "text-[10px] font-black text-blue-400 uppercase tracking-widest mb-0.5", children: "\u0110i\u1EC3m xu\u1EA5t ph\u00E1t" }), _jsx("p", { className: "text-sm font-bold text-gray-800 truncate", children: startPoint.name })] })] })), endPoint && (_jsxs("div", { className: "bg-white p-4 rounded-2xl border border-green-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow", children: [_jsx("div", { className: "w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center text-green-600 shadow-inner", children: _jsx(Flag, { className: "w-5 h-5" }) }), _jsxs("div", { className: "overflow-hidden", children: [_jsx("p", { className: "text-[10px] font-black text-green-400 uppercase tracking-widest mb-0.5", children: "\u0110i\u1EC3m k\u1EBFt th\u00FAc" }), _jsx("p", { className: "text-sm font-bold text-gray-800 truncate", children: endPoint.name })] })] }))] }), _jsxs("div", { className: `${activeTab === 'plan' && viewMode === 'map' ? 'w-full' : 'max-w-2xl mx-auto'} mt-6 px-4 pb-24`, children: [_jsx("div", { className: "bg-white rounded-2xl shadow-lg border border-gray-100 p-1 flex mb-6 sticky top-20 z-20", children: tabs.map((tab) => (_jsxs("button", { onClick: () => setActiveTab(tab.id), className: `flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${activeTab === tab.id
|
||||
? 'bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'text-gray-400 hover:text-gray-600'}`, children: [(0, jsx_runtime_1.jsx)(tab.icon, { className: `w-4 h-4 mr-2 ${activeTab === tab.id ? 'animate-pulse' : ''}` }), tab.label] }, tab.id))) }), (0, jsx_runtime_1.jsxs)("div", { className: "transition-opacity duration-300", children: [activeTab === 'plan' && ((0, jsx_runtime_1.jsx)("div", { className: "animate-in fade-in slide-in-from-bottom-4", children: (0, jsx_runtime_1.jsx)(ItineraryTimeline_1.ItineraryTimeline, {}) })), activeTab === 'expense' && ((0, jsx_runtime_1.jsx)("div", { className: "animate-in fade-in slide-in-from-bottom-4", children: (0, jsx_runtime_1.jsx)(ExpenseManager_1.ExpenseManager, {}) })), activeTab === 'photo' && ((0, jsx_runtime_1.jsx)("div", { className: "grid grid-cols-3 gap-2 animate-in fade-in", children: [1, 2, 3, 4, 5, 6].map((i) => ((0, jsx_runtime_1.jsxs)("div", { className: "aspect-square bg-gray-100 rounded-lg overflow-hidden relative group", children: [(0, jsx_runtime_1.jsx)("div", { className: "absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" }), (0, jsx_runtime_1.jsx)("img", { src: `https://picsum.photos/seed/${i + 10}/400/400`, alt: "Tour photo", className: "w-full h-full object-cover" })] }, i))) }))] })] }), (0, jsx_runtime_1.jsx)("div", { className: "fixed bottom-6 left-1/2 -translate-x-1/2 z-40", children: (0, jsx_runtime_1.jsx)("button", { className: "bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold", children: activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh' }) })] }));
|
||||
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'}`, children: [_jsx(tab.icon, { className: `w-4 h-4 mr-2 ${activeTab === tab.id ? 'scale-110' : ''}` }), tab.label] }, tab.id))) }), _jsxs("div", { className: "transition-opacity duration-300", children: [activeTab === 'plan' && (_jsxs("div", { className: "animate-in fade-in slide-in-from-bottom-2", children: [_jsx("div", { className: "flex justify-center mb-6", children: _jsxs("div", { className: "bg-gray-100 p-1 rounded-2xl flex gap-1", children: [_jsxs("button", { onClick: () => setViewMode('timeline'), className: `flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'timeline' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`, children: [_jsx(List, { className: "w-3.5 h-3.5" }), " Danh s\u00E1ch"] }), _jsxs("button", { onClick: () => setViewMode('map'), className: `flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'map' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`, children: [_jsx(MapIconLucide, { className: "w-3.5 h-3.5" }), " B\u1EA3n \u0111\u1ED3"] })] }) }), viewMode === 'timeline' ? (_jsx(ItineraryTimeline, { onAddLocation: (legId) => {
|
||||
setTargetLegId(legId);
|
||||
setEditingLocation(null);
|
||||
setIsAddLocationOpen(true);
|
||||
}, onEditLocation: (loc) => {
|
||||
setEditingLocation(loc);
|
||||
setTargetLegId(loc.legId);
|
||||
setMapCenter([loc.latitude, loc.longitude]);
|
||||
setIsAddLocationOpen(true);
|
||||
} })) : (_jsxs("div", { className: "h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative", children: [_jsxs(MapContainer, { center: initialViewState?.center || mapCenter, zoom: mapZoom, className: "h-full w-full", preferCanvas: true, children: [_jsx(TileLayer, { url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" }), canEdit && _jsx(MapContextMenu, { onAction: handleMapAction }), _jsx(MapTourBounds, { locations: allLocations }), allLocations.length > 1 && (_jsx(Polyline, { positions: allLocations.map(l => [l.latitude, l.longitude]), color: "#3b82f6", weight: 3, dashArray: "5, 10", smoothFactor: 1.5 })), _jsx(MarkerClusterGroup, { chunkedLoading: true, children: legs.flatMap(l => l.locations).map((loc) => {
|
||||
const isStart = startPoint?.id === loc.id;
|
||||
const isEnd = endPoint?.id === loc.id;
|
||||
const icon = isStart ? START_ICON : isEnd ? END_ICON : VISIT_ICON;
|
||||
return (_jsx(Marker, { position: [loc.latitude, loc.longitude], icon: icon, children: _jsxs(Popup, { children: [_jsx("div", { className: "font-bold", children: loc.name }), _jsx("div", { className: "text-xs text-gray-500", children: loc.type })] }) }, loc.id));
|
||||
}) })] }), _jsx("div", { className: "absolute top-4 left-4 z-[1000] bg-white/90 backdrop-blur-md p-3 rounded-2xl text-[10px] font-bold text-gray-500 shadow-lg border border-white", children: "M\u1EB9o: Nh\u1EA5n gi\u1EEF (Mobile) ho\u1EB7c Chu\u1ED9t ph\u1EA3i \u0111\u1EC3 ghim \u0111\u1ECBa \u0111i\u1EC3m" })] }))] })), activeTab === 'expense' && (_jsx("div", { className: "animate-in fade-in slide-in-from-bottom-4", children: _jsx(ExpenseManager, {}) })), activeTab === 'photo' && (_jsx("div", { className: "grid grid-cols-3 gap-1.5 animate-in fade-in", children: [1, 2, 3, 4, 5, 6].map((i) => (_jsxs("div", { className: "aspect-square bg-gray-200 rounded-xl overflow-hidden relative group border border-white", children: [_jsx("div", { className: "absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" }), _jsx("img", { src: `https://picsum.photos/seed/${i + 10}/400/400`, alt: "Tour photo", className: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" })] }, i))) })), activeTab === 'settings' && (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "p-6 bg-white rounded-3xl border border-dashed border-gray-200 animate-in zoom-in-95", children: [_jsxs("div", { className: "flex items-center gap-3 mb-4", children: [_jsx(Clock, { className: "w-6 h-6 text-blue-500" }), _jsx("h3", { className: "text-lg font-bold text-gray-900", children: "Y\u00EAu c\u1EA7u tham gia" }), _jsxs("span", { className: "text-xs font-bold text-gray-500 bg-gray-100 px-2 py-1 rounded-full", children: [joinRequests.length, " \u0111ang ch\u1EDD"] })] }), _jsxs("div", { className: "space-y-2", children: [joinRequests.map((req) => (_jsxs("div", { className: "flex items-center justify-between gap-3 p-3 rounded-2xl border border-gray-100 bg-white", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "w-9 h-9 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-700 font-bold text-sm", children: req.user?.name?.charAt(0) || '?' }), _jsxs("div", { children: [_jsx("div", { className: "text-sm font-bold text-gray-800", children: req.user?.name || req.userId }), _jsxs("div", { className: "text-[11px] text-gray-500", children: ["\u0110\u01B0\u1EE3c m\u1EDDi b\u1EDFi ", req.requestedBy?.name, " \u2022 ", new Date(req.createdAt).toLocaleString('vi-VN')] })] })] }), _jsxs("div", { className: "flex gap-2", children: [_jsx("button", { disabled: joinRequestActionId === req.id, onClick: async () => {
|
||||
if (!currentTour)
|
||||
return;
|
||||
setConfirmState({
|
||||
open: true,
|
||||
title: 'Chấp nhận yêu cầu',
|
||||
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`,
|
||||
onConfirm: async () => {
|
||||
setJoinRequestActionId(req.id);
|
||||
try {
|
||||
await acceptJoinRequest(currentTour.id, req.id);
|
||||
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||
}
|
||||
catch (e) {
|
||||
notificationModal.openModal('Thông báo', e.message || 'Không thể chấp nhận yêu cầu', 'error');
|
||||
}
|
||||
finally {
|
||||
setJoinRequestActionId(null);
|
||||
setConfirmState({ open: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
}, className: "p-2 rounded-xl bg-green-50 text-green-700 hover:bg-green-100 disabled:opacity-50", "aria-label": "Accept", children: _jsx(Check, { className: "w-4 h-4" }) }), _jsx("button", { disabled: joinRequestActionId === req.id, onClick: async () => {
|
||||
if (!currentTour)
|
||||
return;
|
||||
setConfirmState({
|
||||
open: true,
|
||||
title: 'Từ chối yêu cầu',
|
||||
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`,
|
||||
onConfirm: async () => {
|
||||
setJoinRequestActionId(req.id);
|
||||
try {
|
||||
await rejectJoinRequest(currentTour.id, req.id);
|
||||
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||
}
|
||||
catch (e) {
|
||||
notificationModal.openModal('Thông báo', e.message || 'Không thể từ chối yêu cầu', 'error');
|
||||
}
|
||||
finally {
|
||||
setJoinRequestActionId(null);
|
||||
setConfirmState({ open: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
}, className: "p-2 rounded-xl bg-red-50 text-red-700 hover:bg-red-100 disabled:opacity-50", "aria-label": "Reject", children: _jsx(X, { className: "w-4 h-4" }) })] })] }, req.id))), joinRequests.length === 0 && (_jsx("div", { className: "text-center py-8 text-sm text-gray-500", children: "Kh\u00F4ng c\u00F3 y\u00EAu c\u1EA7u tham gia n\u00E0o \u0111ang ch\u1EDD ph\u00EA duy\u1EC7t." }))] })] }), _jsxs("div", { className: "p-8 text-center bg-gray-50 rounded-3xl border border-dashed border-gray-200", children: [_jsx(Settings, { className: "w-10 h-10 text-gray-300 mx-auto mb-3" }), _jsx("p", { className: "text-gray-500 font-medium", children: "T\u00EDnh n\u0103ng c\u00E0i \u0111\u1EB7t kh\u00E1c \u0111ang \u0111\u01B0\u1EE3c c\u1EADp nh\u1EADt..." })] })] }))] })] }), canEdit && (_jsx("div", { className: "fixed bottom-6 left-1/2 -translate-x-1/2 z-40", children: _jsx("button", { onClick: () => {
|
||||
setTargetLegId(null);
|
||||
setEditingLocation(null);
|
||||
if (activeTab === 'plan')
|
||||
setIsAddLocationOpen(true);
|
||||
}, className: "bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold", children: activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh' }) })), currentTour && (_jsx(AddMemberModal, { isOpen: isAddMemberOpen, onClose: () => setIsAddMemberOpen(false), tourId: currentTour.id, participants: currentTour.participants || [], joinRequests: joinRequests, onRemoveMember: (userId) => removeMember(currentTour.id, userId), onMemberAdded: () => fetchTour(currentTour.id), userRole: userRole || undefined })), currentTour && (_jsx(AddLocationModal, { isOpen: isAddLocationOpen, onClose: () => setIsAddLocationOpen(false), initialLegId: targetLegId || undefined, editingLocation: editingLocation, tourId: currentTour.id })), isMemberDetailOpen && selectedMember && (_jsxs("div", { className: "fixed inset-0 z-[2100] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/60 backdrop-blur-sm", onClick: () => setIsMemberDetailOpen(false) }), _jsxs("div", { className: "relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "w-12 h-12 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold", children: selectedMember.user?.name?.charAt(0) || '?' }), _jsxs("div", { children: [_jsx("div", { className: "text-base font-bold text-gray-900", children: selectedMember.user?.name || 'Chưa đặt tên' }), _jsx("div", { className: "text-xs text-gray-500", children: selectedMember.user?.email }), _jsx("div", { className: "text-[10px] font-semibold text-gray-500", children: selectedMember.role })] })] }), (selectedMember.user?.phone || selectedMember.user?.address) && (_jsxs("div", { className: "mt-3 text-xs text-gray-600 space-y-1", children: [selectedMember.user?.phone && _jsxs("div", { children: ["\uD83D\uDCDE ", selectedMember.user.phone] }), selectedMember.user?.address && _jsxs("div", { children: ["\uD83D\uDCCD ", selectedMember.user.address] })] })), _jsxs("div", { className: "mt-4 flex justify-end gap-2", children: [_jsx("button", { onClick: () => setIsMemberDetailOpen(false), className: "px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100", children: "\u0110\u00F3ng" }), canEdit && selectedMember.role !== 'OWNER' && (_jsx("button", { onClick: async () => {
|
||||
if (!currentTour || !selectedMember)
|
||||
return;
|
||||
try {
|
||||
await removeMember(currentTour.id, selectedMember.userId);
|
||||
setIsMemberDetailOpen(false);
|
||||
}
|
||||
catch (e) {
|
||||
notificationModal.openModal('Thông báo', 'Không thể xóa thành viên', 'error');
|
||||
}
|
||||
}, className: "px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold", children: "X\u00F3a" })), canEdit && selectedMember.role === 'OWNER' && (_jsx("button", { onClick: () => {
|
||||
setIsMemberDetailOpen(false);
|
||||
setIsAddMemberOpen(true);
|
||||
}, className: "px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl text-sm font-bold", children: "M\u1EDDi th\u00EAm ng\u01B0\u1EDDi" }))] })] })] })), _jsx(ConfirmModal, { isOpen: confirmState.open, title: confirmState.title, message: confirmState.message, onConfirm: () => confirmState.onConfirm?.(), onCancel: () => setConfirmState({ open: false }) }), _jsx(NotificationModal, { isOpen: notificationModal.modalState?.isOpen ?? false, title: notificationModal.modalState?.title, message: notificationModal.modalState?.message, type: notificationModal.modalState?.type, onConfirm: () => notificationModal.closeModal() })] }));
|
||||
};
|
||||
exports.TourDetailPage = TourDetailPage;
|
||||
//# sourceMappingURL=TourDetailPage.js.map
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
interface UserManagementModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
export declare const UserManagementModal: React.FC<UserManagementModalProps>;
|
||||
export {};
|
||||
Vendored
+67
@@ -0,0 +1,67 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, User, Trash2, Shield, ShieldAlert, Lock, Unlock, Loader2 } from 'lucide-react';
|
||||
export const UserManagementModal = ({ isOpen, onClose }) => {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const response = await fetch(`${API_BASE}/api/v1/users`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error('Không thể tải danh sách người dùng');
|
||||
const data = await response.json();
|
||||
setUsers(data);
|
||||
}
|
||||
catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (isOpen)
|
||||
fetchUsers();
|
||||
}, [isOpen]);
|
||||
const handleToggleBlock = async (id) => {
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
await fetch(`${API_BASE}/api/v1/users/block/${id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
fetchUsers();
|
||||
}
|
||||
catch (err) {
|
||||
alert('Lỗi khi thay đổi trạng thái block');
|
||||
}
|
||||
};
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Bạn có chắc chắn muốn xóa người dùng này?'))
|
||||
return;
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const res = await fetch(`${API_BASE}/api/v1/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message);
|
||||
}
|
||||
fetchUsers();
|
||||
}
|
||||
catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
if (!isOpen)
|
||||
return null;
|
||||
return (_jsxs("div", { className: "fixed inset-0 z-[2000] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/60 backdrop-blur-sm", onClick: onClose }), _jsxs("div", { className: "relative w-full max-w-4xl bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]", children: [_jsxs("div", { className: "p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50/50", children: [_jsxs("div", { children: [_jsxs("h2", { className: "text-2xl font-bold text-gray-900 flex items-center gap-2", children: [_jsx(Shield, { className: "w-6 h-6 text-blue-600" }), " Qu\u1EA3n l\u00FD ng\u01B0\u1EDDi d\u00F9ng"] }), _jsx("p", { className: "text-sm text-gray-500", children: "Qu\u1EA3n tr\u1ECB vi\u00EAn c\u00F3 quy\u1EC1n th\u00EAm, s\u1EEDa, x\u00F3a ho\u1EB7c kh\u00F3a t\u00E0i kho\u1EA3n." })] }), _jsx("button", { onClick: onClose, className: "p-2 hover:bg-gray-200 rounded-full transition-colors", children: _jsx(X, { className: "w-6 h-6 text-gray-400" }) })] }), _jsx("div", { className: "flex-1 overflow-y-auto p-6", children: loading ? (_jsx("div", { className: "flex justify-center py-20", children: _jsx(Loader2, { className: "w-10 h-10 animate-spin text-blue-600" }) })) : error ? (_jsx("div", { className: "p-4 bg-red-50 text-red-600 rounded-xl font-bold", children: error })) : (_jsxs("table", { className: "w-full text-left border-collapse", children: [_jsx("thead", { children: _jsxs("tr", { className: "text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100", children: [_jsx("th", { className: "pb-4 font-bold px-2", children: "Ng\u01B0\u1EDDi d\u00F9ng" }), _jsx("th", { className: "pb-4 font-bold", children: "Vai tr\u00F2" }), _jsx("th", { className: "pb-4 font-bold", children: "Tr\u1EA1ng th\u00E1i" }), _jsx("th", { className: "pb-4 font-bold text-right", children: "Thao t\u00E1c" })] }) }), _jsx("tbody", { className: "divide-y divide-gray-50", children: users.map(u => (_jsxs("tr", { className: "group hover:bg-gray-50/50 transition-colors", children: [_jsx("td", { className: "py-4 px-2", children: _jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold", children: u.name?.charAt(0) || _jsx(User, { className: "w-5 h-5" }) }), _jsxs("div", { children: [_jsx("div", { className: "font-bold text-gray-900", children: u.name || 'N/A' }), _jsx("div", { className: "text-xs text-gray-400", children: u.email })] })] }) }), _jsx("td", { className: "py-4", children: u.isAdmin ? (_jsx("span", { className: "px-2 py-1 bg-purple-50 text-purple-600 text-[10px] font-black rounded-md border border-purple-100", children: "ADMIN" })) : (_jsx("span", { className: "px-2 py-1 bg-gray-50 text-gray-500 text-[10px] font-black rounded-md border border-gray-100", children: "USER" })) }), _jsx("td", { className: "py-4", children: u.isBlocked ? (_jsxs("span", { className: "flex items-center gap-1 text-red-500 text-xs font-bold", children: [_jsx(ShieldAlert, { className: "w-3 h-3" }), " \u0110\u00E3 kh\u00F3a"] })) : (_jsx("span", { className: "text-green-500 text-xs font-bold", children: "\u0110ang ho\u1EA1t \u0111\u1ED9ng" })) }), _jsx("td", { className: "py-4 text-right", children: _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx("button", { onClick: () => handleToggleBlock(u.id), className: `p-2 rounded-xl transition-all ${u.isBlocked ? 'bg-green-50 text-green-600 hover:bg-green-100' : 'bg-amber-50 text-amber-600 hover:bg-amber-100'}`, title: u.isBlocked ? 'Mở khóa' : 'Khóa tài khoản', children: u.isBlocked ? _jsx(Unlock, { className: "w-4 h-4" }) : _jsx(Lock, { className: "w-4 h-4" }) }), _jsx("button", { onClick: () => handleDelete(u.id), className: "p-2 bg-red-50 text-red-600 rounded-xl hover:bg-red-100 transition-all", title: "X\u00F3a ng\u01B0\u1EDDi d\u00F9ng", children: _jsx(Trash2, { className: "w-4 h-4" }) })] }) })] }, u.id))) })] })) })] })] }));
|
||||
};
|
||||
//# sourceMappingURL=UserManagementModal.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"UserManagementModal.js","sourceRoot":"","sources":["../UserManagementModal.tsx"],"names":[],"mappings":"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"}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
import { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service.js';
|
||||
export declare class AdminGuard implements CanActivate {
|
||||
private prisma;
|
||||
constructor(prisma: PrismaService);
|
||||
canActivate(context: ExecutionContext): Promise<boolean>;
|
||||
}
|
||||
Vendored
+37
@@ -0,0 +1,37 @@
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
import { Injectable, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service.js';
|
||||
let AdminGuard = class AdminGuard {
|
||||
constructor(prisma) {
|
||||
this.prisma = prisma;
|
||||
}
|
||||
async canActivate(context) {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
if (!user || !user.id) {
|
||||
throw new ForbiddenException('Yêu cầu xác thực không hợp lệ. Vui lòng đăng nhập.');
|
||||
}
|
||||
const dbUser = await this.prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { isAdmin: true, isBlocked: true },
|
||||
});
|
||||
if (!dbUser || !dbUser.isAdmin || dbUser.isBlocked) {
|
||||
throw new ForbiddenException('Truy cập bị từ chối. Bạn không có quyền quản trị viên hệ thống.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
AdminGuard = __decorate([
|
||||
Injectable(),
|
||||
__metadata("design:paramtypes", [PrismaService])
|
||||
], AdminGuard);
|
||||
export { AdminGuard };
|
||||
//# sourceMappingURL=admin.guard.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"admin.guard.js","sourceRoot":"","sources":["../admin.guard.ts"],"names":[],"mappings":";;;;;;;;;AAAA,OAAO,EAAE,UAAU,EAAiC,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAC/F,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAG7C,IAAM,UAAU,GAAhB,MAAM,UAAU;IACrB,YAAoB,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAE7C,KAAK,CAAC,WAAW,CAAC,OAAyB;QACzC,MAAM,OAAO,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,UAAU,EAAE,CAAC;QAGpD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;QAE1B,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC;YACtB,MAAM,IAAI,kBAAkB,CAAC,oDAAoD,CAAC,CAAC;QACrF,CAAC;QAGD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC;YAC/C,KAAK,EAAE,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE;YACtB,MAAM,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE;SAC3C,CAAC,CAAC;QAEH,IAAI,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,IAAI,MAAM,CAAC,SAAS,EAAE,CAAC;YACnD,MAAM,IAAI,kBAAkB,CAAC,iEAAiE,CAAC,CAAC;QAClG,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAzBY,UAAU;IADtB,UAAU,EAAE;qCAEiB,aAAa;GAD9B,UAAU,CAyBtB"}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
export interface NotificationModalProps {
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
type?: 'info' | 'success' | 'warning' | 'error';
|
||||
confirmButtonText?: string;
|
||||
cancelButtonText?: string;
|
||||
}
|
||||
export declare const NotificationModal: React.FC<NotificationModalProps>;
|
||||
export declare const useNotificationModal: () => {
|
||||
modalState: {
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
type?: "info" | "success" | "warning" | "error";
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
};
|
||||
openModal: (title: string, message: string, type?: "info" | "success" | "warning" | "error", onConfirm?: () => void, onCancel?: () => void) => () => void;
|
||||
closeModal: () => void;
|
||||
};
|
||||
export default NotificationModal;
|
||||
Vendored
+81
@@ -0,0 +1,81 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState } from 'react';
|
||||
const Icons = {
|
||||
info: (props) => (_jsxs("svg", { ...props, viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "2" }), _jsx("path", { d: "M12 16v-4", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" }), _jsx("path", { d: "M12 8h.01", stroke: "currentColor", strokeWidth: "3", strokeLinecap: "round" })] })),
|
||||
success: (props) => (_jsxs("svg", { ...props, viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "2" }), _jsx("path", { d: "M8 12l2.5 2.5L15.5 9", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" })] })),
|
||||
warning: (props) => (_jsxs("svg", { ...props, viewBox: "0 0 24 24", fill: "none", children: [_jsx("path", { d: "M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z", stroke: "currentColor", strokeWidth: "2" }), _jsx("line", { x1: "12", y1: "9", x2: "12", y2: "13", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" }), _jsx("circle", { cx: "12", cy: "17", r: "1", fill: "currentColor" })] })),
|
||||
error: (props) => (_jsxs("svg", { ...props, viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "2" }), _jsx("line", { x1: "15", y1: "9", x2: "9", y2: "15", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" }), _jsx("line", { x1: "9", y1: "9", x2: "15", y2: "15", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" })] })),
|
||||
};
|
||||
const defaultProps = {
|
||||
title: 'Thông báo',
|
||||
type: 'info',
|
||||
confirmButtonText: 'OK',
|
||||
cancelButtonText: 'Hủy',
|
||||
};
|
||||
export const NotificationModal = ({ isOpen, title = defaultProps.title, message, onConfirm, onCancel, type = defaultProps.type, confirmButtonText = defaultProps.confirmButtonText, cancelButtonText = defaultProps.cancelButtonText, }) => {
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const getTypeStyles = () => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return { bg: '#d4edda', border: '#c3e6cb', text: '#155724' };
|
||||
case 'warning':
|
||||
return { bg: '#fff3cd', border: '#ffeeba', text: '#856404' };
|
||||
case 'error':
|
||||
return { bg: '#f8d7da', border: '#f5c6cb', text: '#721c24' };
|
||||
default:
|
||||
return { bg: '#e2e3e5', border: '#d6d8db', text: '#383d41' };
|
||||
}
|
||||
};
|
||||
const styles = getTypeStyles();
|
||||
const getAnimationClass = () => {
|
||||
if (!isOpen)
|
||||
return 'opacity-0 translate-y-4';
|
||||
if (isAnimating && onCancel)
|
||||
return 'animate-fade-out';
|
||||
return 'animate-fade-in';
|
||||
};
|
||||
const handleConfirm = () => {
|
||||
setIsAnimating(true);
|
||||
onConfirm?.();
|
||||
setTimeout(() => setIsAnimating(false), 300);
|
||||
};
|
||||
const handleCancel = () => {
|
||||
setIsAnimating(true);
|
||||
onCancel?.();
|
||||
setTimeout(() => setIsAnimating(false), 300);
|
||||
};
|
||||
if (!isOpen)
|
||||
return null;
|
||||
return (_jsx("div", { className: "fixed inset-0 bg-black/50 flex items-center justify-center z-[100] p-4", children: _jsxs("div", { className: `bg-white rounded-lg shadow-xl max-w-md w-full transform transition-all duration-300 ${getAnimationClass()}`, role: "alertdialog", "aria-modal": "true", "aria-labelledby": "modal-title", "aria-describedby": "modal-message", children: [_jsx("div", { className: `p-6 border-b ${styles.border}`, children: _jsxs("div", { className: "flex items-center gap-3", children: [type === 'success' && _jsx(Icons.success, { className: "w-5 h-5 text-green-600" }), type === 'warning' && _jsx(Icons.warning, { className: "w-5 h-5 text-yellow-600" }), type === 'error' && _jsx(Icons.error, { className: "w-5 h-5 text-red-600" }), type === 'info' && _jsx(Icons.info, { className: "w-5 h-5 text-blue-600" }), _jsx("h2", { id: "modal-title", className: `text-xl font-semibold ${styles.text}`, children: title })] }) }), _jsx("div", { className: "p-6", children: _jsx("p", { id: "modal-message", className: "text-gray-700 leading-relaxed", children: message }) }), _jsxs("div", { className: `px-6 py-4 flex justify-end gap-3 border-t ${styles.border}`, children: [onCancel && (_jsx("button", { onClick: handleCancel, className: "px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-md transition-colors", children: cancelButtonText })), onConfirm && (_jsx("button", { onClick: handleConfirm, className: `px-4 py-2 text-white rounded-md font-medium transition-colors ${type === 'error'
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: 'bg-blue-600 hover:bg-blue-700'}`, children: confirmButtonText }))] })] }) }));
|
||||
};
|
||||
export const useNotificationModal = () => {
|
||||
const [modalState, setModalState] = useState(null);
|
||||
const openModal = (title, message, type = 'info', onConfirm, onCancel) => {
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
title,
|
||||
message,
|
||||
type,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
if (onCancel) {
|
||||
setModalState((prev) => ({ ...prev, isOpen: false }));
|
||||
}
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
};
|
||||
const closeModal = () => {
|
||||
setModalState((prev) => prev ? { ...prev, isOpen: false } : null);
|
||||
};
|
||||
return {
|
||||
modalState,
|
||||
openModal,
|
||||
closeModal,
|
||||
};
|
||||
};
|
||||
export default NotificationModal;
|
||||
//# sourceMappingURL=NotificationModal.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user