Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5530f36df | |||
| dcaf71032c | |||
| aec9112064 | |||
| 8dac79fc4b | |||
| 75cf96898c | |||
| f52580717f | |||
| 879f20985c | |||
| 4edfd0eb59 | |||
| 55f0a8e775 | |||
| 7812d1395b | |||
| c9ef98b2ff | |||
| 71f4aa9ecd | |||
| 34e2af8825 | |||
| 4716b841dc | |||
| 967f6b4f6a |
@@ -1,77 +0,0 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
|
||||||
import { Wallet, Users, Info } from 'lucide-react';
|
|
||||||
import { useTourStore } from './useTourStore.js';
|
|
||||||
|
|
||||||
export const ExpenseManager = () => {
|
|
||||||
const { legs } = useTourStore();
|
|
||||||
const [adults, setAdults] = useState(2);
|
|
||||||
const [children, setChildren] = useState(1);
|
|
||||||
const [discount, setDiscount] = useState(30);
|
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
|
||||||
const totalAmount = legs.reduce((acc, leg) =>
|
|
||||||
acc + leg.expenses.reduce((lAcc: number, exp: any) => lAcc + Number(exp.amount), 0), 0
|
|
||||||
);
|
|
||||||
|
|
||||||
const childRateFactor = 1 - (discount / 100);
|
|
||||||
const weightedCount = adults + (children * childRateFactor);
|
|
||||||
const adultPrice = totalAmount / weightedCount;
|
|
||||||
const childPrice = adultPrice * childRateFactor;
|
|
||||||
|
|
||||||
return {
|
|
||||||
total: totalAmount,
|
|
||||||
adultPrice: Math.round(adultPrice),
|
|
||||||
childPrice: Math.round(childPrice)
|
|
||||||
};
|
|
||||||
}, [legs, adults, children, discount]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4">
|
|
||||||
<div className="bg-white p-6 rounded-2xl border border-gray-100 shadow-sm">
|
|
||||||
<div className="flex items-center gap-2 mb-6 text-blue-600">
|
|
||||||
<Users className="w-5 h-5" />
|
|
||||||
<h3 className="font-bold">Cấu hình thành viên</h3>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-400 block mb-1">Người lớn</label>
|
|
||||||
<input type="number" value={adults} onChange={e => setAdults(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-400 block mb-1">Trẻ em</label>
|
|
||||||
<input type="number" value={children} onChange={e => setChildren(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-400 block mb-1">Giảm trẻ em (%)</label>
|
|
||||||
<input type="number" value={discount} onChange={e => setDiscount(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-blue-600 rounded-2xl p-6 text-white shadow-lg shadow-blue-200">
|
|
||||||
<div className="flex justify-between items-start mb-8">
|
|
||||||
<div>
|
|
||||||
<p className="text-blue-100 text-sm">Tổng chi phí chuyến đi</p>
|
|
||||||
<h2 className="text-3xl font-bold mt-1">{totals.total.toLocaleString()} VND</h2>
|
|
||||||
</div>
|
|
||||||
<Wallet className="w-8 h-8 opacity-20" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4 border-t border-blue-500 pt-6">
|
|
||||||
<div>
|
|
||||||
<p className="text-blue-100 text-xs uppercase tracking-wider font-semibold">Mỗi người lớn</p>
|
|
||||||
<p className="text-xl font-bold">{totals.adultPrice.toLocaleString()}đ</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-blue-100 text-xs uppercase tracking-wider font-semibold">Mỗi trẻ em (-{discount}%)</p>
|
|
||||||
<p className="text-xl font-bold">{totals.childPrice.toLocaleString()}đ</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-gray-400 text-xs px-2">
|
|
||||||
<Info className="w-4 h-4" />
|
|
||||||
<p>Chi phí được tự động tính toán dựa trên hóa đơn của các chặng.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+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,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
+1
@@ -0,0 +1 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
Vendored
+1033
File diff suppressed because it is too large
Load Diff
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -1,8 +1,10 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/nest-cli",
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": ".",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true
|
"deleteOutDir": true,
|
||||||
|
"assets": ["**/*.prisma", "schema.sql"],
|
||||||
|
"watchAssets": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"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": "node --loader ts-node/esm seed.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^11.0.23",
|
||||||
|
"@types/node": "^20.14.10",
|
||||||
|
"@types/pg": "^8.11.6",
|
||||||
|
"dotenv-cli": "^7.4.2",
|
||||||
|
"prisma": "^5.16.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.5.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@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",
|
||||||
|
"@prisma/client": "^5.16.2",
|
||||||
|
"@prisma/adapter-pg": "^5.16.2",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"pg": "^8.12.0",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -6,6 +6,9 @@ import { Pool } from 'pg';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
console.log('--- [PRISMA CHECK] ---');
|
||||||
|
console.log('DATABASE_URL nhận được:', process.env.DATABASE_URL ? 'ĐÃ ĐỌC THÀNH CÔNG ✔️' : 'VẪN BỊ UNDEFINED ❌');
|
||||||
|
console.log('----------------------');
|
||||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
const adapter = new PrismaPg(pool);
|
const adapter = new PrismaPg(pool);
|
||||||
super({ adapter });
|
super({ adapter });
|
||||||
@@ -3,10 +3,12 @@
|
|||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
previewFeatures = ["driverAdapters"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Enums ---
|
// --- Enums ---
|
||||||
@@ -76,11 +78,16 @@ model User {
|
|||||||
model Tour {
|
model Tour {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
title String
|
title String
|
||||||
|
description String?
|
||||||
startDate DateTime?
|
startDate DateTime?
|
||||||
endDate DateTime?
|
endDate DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
totalCost Decimal @default(0) @db.Decimal(15, 2)
|
totalCost Decimal @default(0) @db.Decimal(15, 2)
|
||||||
|
|
||||||
|
adultCount Int @default(1)
|
||||||
|
childCount Int @default(0)
|
||||||
|
childDiscount Int @default(30)
|
||||||
|
|
||||||
createdById String
|
createdById String
|
||||||
creator User @relation("TourCreator", fields: [createdById], references: [id])
|
creator User @relation("TourCreator", fields: [createdById], references: [id])
|
||||||
|
|
||||||
@@ -122,6 +129,9 @@ model Leg {
|
|||||||
tourId String
|
tourId String
|
||||||
sequence Int
|
sequence Int
|
||||||
note String? @db.Text
|
note String? @db.Text
|
||||||
|
startDate DateTime?
|
||||||
|
endDate DateTime?
|
||||||
|
description String? @db.Text
|
||||||
|
|
||||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||||
locations Location[]
|
locations Location[]
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||||
import { PrismaService } from './prisma.service.js';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminGuard implements CanActivate {
|
export class AdminGuard implements CanActivate {
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
import { PrismaService } from './prisma.service.js';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||||
import { PrismaService } from './prisma.service.js';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TourRoleGuard implements CanActivate {
|
export class TourRoleGuard implements CanActivate {
|
||||||
@@ -1,14 +1,40 @@
|
|||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
// Sửa đường dẫn: lùi 2 cấp từ backend/src để ra root monorepo
|
||||||
|
const envPath = path.resolve(process.cwd(), '..', '.env');
|
||||||
|
dotenv.config({ path: envPath });
|
||||||
|
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException } from '@nestjs/common';
|
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException } from '@nestjs/common';
|
||||||
import { PrismaService } from './prisma.service.js';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import 'dotenv/config';
|
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { AdminGuard } from './admin.guard.js';
|
import { AdminGuard } from './auth/admin.guard';
|
||||||
import { JwtModule, JwtService } from '@nestjs/jwt';
|
import { JwtModule, JwtService } from '@nestjs/jwt';
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard.js';
|
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||||
import { JwtStrategy } from './jwt.strategy.js';
|
import { JwtStrategy } from './auth/jwt.strategy';
|
||||||
import { TourRoleGuard } from './rbac.middleware.js';
|
import { TourRoleGuard } from './common/rbac.middleware';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
if (!process.env.DATABASE_URL) {
|
||||||
|
throw new Error('❌ DATABASE_URL không tồn tại trong biến môi trường! Kiểm tra file .env tại: ' + envPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kiểm tra log xem biến môi trường đã nhận đúng chưa
|
||||||
|
console.log('====================================');
|
||||||
|
console.log('DATABASE_URL:', process.env.DATABASE_URL);
|
||||||
|
console.log('====================================');
|
||||||
|
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
// Bật CORS để cho phép Frontend kết nối API không bị chặn
|
||||||
|
app.enableCors();
|
||||||
|
|
||||||
|
await app.listen(3001);
|
||||||
|
console.log(`🚀 Server is running on: http://localhost:3001`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
class AppController {
|
class AppController {
|
||||||
@@ -18,7 +44,7 @@ class AppController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('v1/auth')
|
@Controller('auth')
|
||||||
class AuthController {
|
class AuthController {
|
||||||
constructor(private prisma: PrismaService, private jwtService: JwtService) {}
|
constructor(private prisma: PrismaService, private jwtService: JwtService) {}
|
||||||
|
|
||||||
@@ -75,19 +101,22 @@ class AuthController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('v1/tours')
|
@Controller('tours')
|
||||||
class TourController {
|
class TourController {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Post()
|
@Post()
|
||||||
async createTour(@Body() body: any, @Req() req: any) {
|
async createTour(@Body() body: any, @Req() req: any) {
|
||||||
const { title, startDate, endDate } = body;
|
const { title, startDate, endDate, adultCount, childCount, childDiscount } = body;
|
||||||
return this.prisma.tour.create({
|
return this.prisma.tour.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
startDate: startDate ? new Date(startDate) : null,
|
startDate: startDate ? new Date(startDate) : null,
|
||||||
endDate: endDate ? new Date(endDate) : null,
|
endDate: endDate ? new Date(endDate) : null,
|
||||||
|
adultCount: adultCount || 1,
|
||||||
|
childCount: childCount || 0,
|
||||||
|
childDiscount: childDiscount || 0,
|
||||||
createdById: req.user.id,
|
createdById: req.user.id,
|
||||||
participants: {
|
participants: {
|
||||||
create: {
|
create: {
|
||||||
@@ -295,8 +324,12 @@ class TourController {
|
|||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
title: body.title,
|
title: body.title,
|
||||||
|
description: body.description,
|
||||||
startDate: body.startDate ? new Date(body.startDate) : undefined,
|
startDate: body.startDate ? new Date(body.startDate) : undefined,
|
||||||
endDate: body.endDate ? new Date(body.endDate) : undefined,
|
endDate: body.endDate ? new Date(body.endDate) : undefined,
|
||||||
|
adultCount: body.adultCount !== undefined ? Number(body.adultCount) : undefined,
|
||||||
|
childCount: body.childCount !== undefined ? Number(body.childCount) : undefined,
|
||||||
|
childDiscount: body.childDiscount !== undefined ? Number(body.childDiscount) : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -471,7 +504,7 @@ class TourController {
|
|||||||
});
|
});
|
||||||
role = participation?.role;
|
role = participation?.role;
|
||||||
}
|
}
|
||||||
if (!role || !(role === 'OWNER' || role === 'MANAGER')) {
|
if (!role || !(role === 'OWNER' || role === 'MANAGER')) {
|
||||||
throw new ForbiddenException('Bạn không có quyền chấp nhận yêu cầu tham gia.');
|
throw new ForbiddenException('Bạn không có quyền chấp nhận yêu cầu tham gia.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,7 +556,7 @@ class TourController {
|
|||||||
});
|
});
|
||||||
role = participation?.role;
|
role = participation?.role;
|
||||||
}
|
}
|
||||||
if (!role || !(role === 'OWNER' || role === 'MANAGER')) {
|
if (!role || role !== 'OWNER') {
|
||||||
throw new ForbiddenException('Bạn không có quyền từ chối yêu cầu tham gia.');
|
throw new ForbiddenException('Bạn không có quyền từ chối yêu cầu tham gia.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,7 +594,7 @@ class TourController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('v1/locations')
|
@Controller('locations')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
class LocationController {
|
class LocationController {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
@@ -618,7 +651,7 @@ class LocationController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('v1/legs')
|
@Controller('legs')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
class LegController {
|
class LegController {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
@@ -629,7 +662,10 @@ class LegController {
|
|||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
note: body.note,
|
note: body.note,
|
||||||
sequence: body.sequence
|
sequence: body.sequence,
|
||||||
|
startDate: body.startDate ? new Date(body.startDate) : undefined,
|
||||||
|
endDate: body.endDate ? new Date(body.endDate) : undefined,
|
||||||
|
description: body.description,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -662,7 +698,7 @@ function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: numbe
|
|||||||
return 12742 * Math.asin(Math.sqrt(a)); // 2 * R; R = 6371 km
|
return 12742 * Math.asin(Math.sqrt(a)); // 2 * R; R = 6371 km
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('v1/routing')
|
@Controller('routing')
|
||||||
class RoutingController {
|
class RoutingController {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
@@ -775,7 +811,7 @@ class RoutingController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('v1/users')
|
@Controller('users')
|
||||||
class UserController {
|
class UserController {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
@@ -843,18 +879,10 @@ class UserController {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AppController, AuthController, TourController, UserController, RoutingController, LegController, LocationController],
|
controllers: [AppController, AuthController, TourController, UserController, RoutingController, LegController, LocationController],
|
||||||
providers: [PrismaService, JwtStrategy, TourRoleGuard],
|
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard],
|
||||||
exports: [PrismaService]
|
exports: [PrismaService]
|
||||||
})
|
})
|
||||||
class AppModule {}
|
class AppModule {}
|
||||||
|
|
||||||
async function bootstrap() {
|
|
||||||
const app = await NestFactory.create(AppModule);
|
|
||||||
app.enableCors(); // Cho phép Frontend gọi API
|
|
||||||
app.setGlobalPrefix('api'); // Tất cả API sẽ bắt đầu bằng /api/...
|
|
||||||
|
|
||||||
const port = process.env.PORT || 3001;
|
|
||||||
await app.listen(port);
|
|
||||||
console.log(`🚀 Server is running on: http://localhost:${port}`);
|
|
||||||
}
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node", // Đảm bảo module resolution là 'node'
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2021",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": false,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"strictBindCallApply": false,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": false,
|
||||||
|
"resolveJsonModule": true, // Cho phép import các file .json
|
||||||
|
"esModuleInterop": true, // Cho phép cú pháp import/export ES Modules với CommonJS
|
||||||
|
"baseUrl": "./",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@ Tài liệu này mô tả kiến trúc tổng thể, mô hình dữ liệu và c
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Cấu Trúc Thư Mục (Directory Structure)
|
## 1. Cấu Trúc Thư Mục (Directory Structure)
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/home/locpham/travelplanning/
|
/home/locpham/travelplanning/
|
||||||
@@ -7,6 +7,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/index.tsx"></script>
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"lucide-react": "^0.284.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
|
"react-leaflet-cluster": "^2.1.0",
|
||||||
|
"zustand": "^5.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.3.1",
|
||||||
|
"@types/leaflet": "^1.9.12",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
|
"autoprefixer": "^10.5.0",
|
||||||
|
"postcss": "^8.5.15",
|
||||||
|
"tailwindcss": "^4.3.1",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^8.0.16"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
"@tailwindcss/postcss": {},
|
"@tailwindcss/postcss": {},
|
||||||
"autoprefixer": {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 225 KiB |
@@ -1,9 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { LandingPage } from './LandingPage.js';
|
import { LandingPage } from '@/pages/LandingPage';
|
||||||
import { TourDetailPage } from './TourDetailPage.js';
|
import { TourDetailPage } from '@/pages/TourDetailPage';
|
||||||
import { ExploreMap } from './ExploreMap.js';
|
import { ExploreMap } from '@/pages/ExploreMap';
|
||||||
import { SignupPage } from './SignupPage.js';
|
import { SignupPage } from '@/pages/SignupPage';
|
||||||
import { useTourStore } from './useTourStore.js';
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
type View = 'landing' | 'explore' | 'detail' | 'signup';
|
type View = 'landing' | 'explore' | 'detail' | 'signup';
|
||||||
@@ -14,9 +14,6 @@ const App = () => {
|
|||||||
const fetchTour = useTourStore(state => state.fetchTour);
|
const fetchTour = useTourStore(state => state.fetchTour);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Tự động xác định địa chỉ IP của Backend dựa trên hostname hiện tại
|
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
|
||||||
|
|
||||||
// Khôi phục phiên đăng nhập từ localStorage
|
// Khôi phục phiên đăng nhập từ localStorage
|
||||||
const savedUser = localStorage.getItem('user');
|
const savedUser = localStorage.getItem('user');
|
||||||
if (savedUser) {
|
if (savedUser) {
|
||||||
@@ -26,7 +23,7 @@ const App = () => {
|
|||||||
setIsUserLoaded(true); // Đánh dấu user đã được load
|
setIsUserLoaded(true); // Đánh dấu user đã được load
|
||||||
|
|
||||||
// Kiểm tra xem hệ thống đã được cài đặt chưa
|
// Kiểm tra xem hệ thống đã được cài đặt chưa
|
||||||
fetch(`${API_BASE}/api/v1/auth/status`)
|
fetch(`/api/v1/auth/status`)
|
||||||
.then(res => res.ok ? res.json() : Promise.reject())
|
.then(res => res.ok ? res.json() : Promise.reject())
|
||||||
.then(data => setIsInitialSetup(!!data.isInitialSetup))
|
.then(data => setIsInitialSetup(!!data.isInitialSetup))
|
||||||
.catch(() => setIsInitialSetup(false));
|
.catch(() => setIsInitialSetup(false));
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { X, MapPin, Loader2, Clock, Map as MapIcon } from 'lucide-react';
|
import { X, MapPin, Loader2, Clock, Map as MapIcon } from 'lucide-react';
|
||||||
import { useTourStore } from './useTourStore.js';
|
import { useTourStore } from '@/store/useTourStore.js';
|
||||||
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
|
|
||||||
@@ -35,8 +35,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setFetchError('');
|
setFetchError('');
|
||||||
try {
|
try {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const res = await fetch(`/api/v1/users?q=${encodeURIComponent(query)}`, {
|
||||||
const res = await fetch(`${API_BASE}/api/v1/users?q=${encodeURIComponent(query)}`, {
|
|
||||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Không thể tải danh sách người dùng');
|
if (!res.ok) throw new Error('Không thể tải danh sách người dùng');
|
||||||
@@ -86,10 +85,9 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
if (!onMemberAdded) return;
|
if (!onMemberAdded) return;
|
||||||
setActionLoading(reqId);
|
setActionLoading(reqId);
|
||||||
try {
|
try {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
|
||||||
const endpoint = action === 'accept'
|
const endpoint = action === 'accept'
|
||||||
? `${API_BASE}/api/v1/tours/${tourId}/join-requests/${reqId}/accept`
|
? `/api/v1/tours/${tourId}/join-requests/${reqId}/accept`
|
||||||
: `${API_BASE}/api/v1/tours/${tourId}/join-requests/${reqId}/reject`;
|
: `/api/v1/tours/${tourId}/join-requests/${reqId}/reject`;
|
||||||
const res = await fetch(endpoint, {
|
const res = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
|
||||||
@@ -111,12 +109,11 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setSubmitError('');
|
setSubmitError('');
|
||||||
try {
|
try {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
|
||||||
const endpoint = canCreateDirectly ? `/api/v1/tours/${tourId}/members` : `/api/v1/tours/${tourId}/join-requests`;
|
const endpoint = canCreateDirectly ? `/api/v1/tours/${tourId}/members` : `/api/v1/tours/${tourId}/join-requests`;
|
||||||
const body = canCreateDirectly
|
const body = canCreateDirectly
|
||||||
? { userId: selectedUser, role }
|
? { userId: selectedUser, role }
|
||||||
: { userId: selectedUser };
|
: { userId: selectedUser };
|
||||||
const res = await fetch(`${API_BASE}${endpoint}`, {
|
const res = await fetch(`${endpoint}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Trash2 } from 'lucide-react';
|
import { Trash2, Users } from 'lucide-react';
|
||||||
import { useTourStore } from './useTourStore.js';
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
|
|
||||||
export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolean, onClose: () => void, onSuccess: (tour: any) => void }) => {
|
export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolean, onClose: () => void, onSuccess: (tour: any) => void }) => {
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [startDate, setStartDate] = useState('');
|
const [startDate, setStartDate] = useState('');
|
||||||
const [endDate, setEndDate] = useState('');
|
const [endDate, setEndDate] = useState('');
|
||||||
|
const [adultCount, setAdultCount] = useState(2);
|
||||||
|
const [childCount, setChildCount] = useState(1);
|
||||||
|
const [childDiscount, setChildDiscount] = useState(30);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const createTour = useTourStore((state) => state.createTour);
|
const createTour = useTourStore((state) => state.createTour);
|
||||||
|
|
||||||
@@ -23,8 +26,7 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const res = await fetch(`/api/v1/users?q=${encodeURIComponent(value)}`, {
|
||||||
const res = await fetch(`${API_BASE}/api/v1/users?q=${encodeURIComponent(value)}`, {
|
|
||||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
|
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Không thể tải người dùng');
|
if (!res.ok) throw new Error('Không thể tải người dùng');
|
||||||
@@ -53,7 +55,15 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
|||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const memberIds = members.map((m) => m.id);
|
const memberIds = members.map((m) => m.id);
|
||||||
const tour = await createTour({ title, startDate, endDate, memberIds });
|
const tour = await createTour({
|
||||||
|
title,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
memberIds,
|
||||||
|
adultCount,
|
||||||
|
childCount,
|
||||||
|
childDiscount
|
||||||
|
});
|
||||||
onSuccess(tour);
|
onSuccess(tour);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -105,6 +115,31 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50/50 p-4 rounded-2xl border border-blue-100 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-blue-600 mb-1">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
<span className="text-xs font-black uppercase tracking-wider">Cơ cấu đoàn & Định mức chi phí</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Người lớn</label>
|
||||||
|
<input type="number" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||||
|
value={adultCount} onChange={e => setAdultCount(Number(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Trẻ em</label>
|
||||||
|
<input type="number" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||||
|
value={childCount} onChange={e => setChildCount(Number(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Giảm trẻ em %</label>
|
||||||
|
<input type="number" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||||
|
value={childDiscount} onChange={e => setChildDiscount(Number(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-blue-400 italic font-medium">* Dùng để tính toán đơn giá bình quân trong báo cáo chi phí.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 mb-2">Thành viên tham gia</label>
|
<label className="block text-sm font-bold text-gray-700 mb-2">Thành viên tham gia</label>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,8 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
||||||
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List } from 'lucide-react';
|
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft } from 'lucide-react';
|
||||||
import { useTourStore } from './useTourStore.js';
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
import { ConfirmModal } from './ConfirmModal.js';
|
import { ConfirmModal } from '@/components/ConfirmModal';
|
||||||
|
|
||||||
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
|
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
|
||||||
if (!actual) return null;
|
if (!actual) return null;
|
||||||
@@ -40,8 +40,30 @@ export const ItineraryTimeline = ({
|
|||||||
onAddLocation,
|
onAddLocation,
|
||||||
onEditLocation
|
onEditLocation
|
||||||
}: { onAddLocation?: (legId: string) => void, onEditLocation?: (location: any) => void }) => {
|
}: { onAddLocation?: (legId: string) => void, onEditLocation?: (location: any) => void }) => {
|
||||||
const { currentTour, legs, optimizeRouting, userRole, addLeg, updateLeg, deleteLeg, initializeLegs, deleteLocation } = useTourStore();
|
// Tối ưu hóa selectors để chỉ lắng nghe những thay đổi cần thiết
|
||||||
|
const currentTour = useTourStore(state => state.currentTour);
|
||||||
|
const legs = useTourStore(state => state.legs);
|
||||||
|
const userRole = useTourStore(state => state.userRole);
|
||||||
|
const optimizeRouting = useTourStore(state => state.optimizeRouting);
|
||||||
|
const addLeg = useTourStore(state => state.addLeg);
|
||||||
|
const updateLeg = useTourStore(state => state.updateLeg);
|
||||||
|
const deleteLeg = useTourStore(state => state.deleteLeg);
|
||||||
|
const initializeLegs = useTourStore(state => state.initializeLegs);
|
||||||
|
const deleteLocation = useTourStore(state => state.deleteLocation);
|
||||||
|
|
||||||
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
|
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
|
||||||
|
const [isLegCountModalOpen, setIsLegCountModalOpen] = useState(false);
|
||||||
|
const [tempLegCount, setTempLegCount] = useState(3);
|
||||||
|
|
||||||
|
// State cho Modal sửa chặng
|
||||||
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
|
const [editingLegData, setEditingLegData] = useState({
|
||||||
|
id: '',
|
||||||
|
note: '',
|
||||||
|
description: '',
|
||||||
|
startDate: '',
|
||||||
|
endDate: ''
|
||||||
|
});
|
||||||
|
|
||||||
const toggleComplete = async (locationId: string) => {
|
const toggleComplete = async (locationId: string) => {
|
||||||
// Gọi API PATCH /api/v1/locations/:id để cập nhật trạng thái
|
// Gọi API PATCH /api/v1/locations/:id để cập nhật trạng thái
|
||||||
@@ -56,17 +78,37 @@ export const ItineraryTimeline = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeclareLegs = async () => {
|
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");
|
setTempLegCount(legs.length > 0 ? legs.length : 3);
|
||||||
const count = parseInt(countStr || "0");
|
setIsLegCountModalOpen(true);
|
||||||
if (count > 0 && currentTour) {
|
};
|
||||||
await initializeLegs(currentTour.id, count);
|
|
||||||
|
const confirmDeclareLegs = async () => {
|
||||||
|
if (tempLegCount > 0 && tempLegCount <= 20 && currentTour) {
|
||||||
|
await initializeLegs(currentTour.id, tempLegCount);
|
||||||
}
|
}
|
||||||
|
setIsLegCountModalOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditLeg = async (leg: any) => {
|
const handleEditLeg = async (leg: any) => {
|
||||||
const note = window.prompt("Sửa ghi chú chặng:", leg.note || "");
|
setEditingLegData({
|
||||||
if (note !== null) {
|
id: leg.id,
|
||||||
await updateLeg(leg.id, { note });
|
note: leg.note || "",
|
||||||
|
description: leg.description || "",
|
||||||
|
startDate: leg.startDate ? leg.startDate.split('T')[0] : "",
|
||||||
|
endDate: leg.endDate ? leg.endDate.split('T')[0] : ""
|
||||||
|
});
|
||||||
|
setIsEditModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveLegEdit = async () => {
|
||||||
|
if (editingLegData.id) {
|
||||||
|
await updateLeg(editingLegData.id, {
|
||||||
|
note: editingLegData.note,
|
||||||
|
description: editingLegData.description,
|
||||||
|
startDate: editingLegData.startDate || null,
|
||||||
|
endDate: editingLegData.endDate || null
|
||||||
|
});
|
||||||
|
setIsEditModalOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -361,6 +403,126 @@ export const ItineraryTimeline = ({
|
|||||||
onConfirm={() => confirmState.onConfirm?.()}
|
onConfirm={() => confirmState.onConfirm?.()}
|
||||||
onCancel={() => setConfirmState({ open: false })}
|
onCancel={() => setConfirmState({ open: false })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Modal Khai báo số chặng (Popover) */}
|
||||||
|
{isLegCountModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-[4000] flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={() => setIsLegCountModalOpen(false)} />
|
||||||
|
<div className="relative w-full max-w-sm bg-white rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h3 className="text-xl font-black text-gray-900">Số chặng lộ trình</h3>
|
||||||
|
<button onClick={() => setIsLegCountModalOpen(false)} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||||
|
<X className="w-5 h-5 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-500 mb-6 leading-relaxed">
|
||||||
|
Bạn muốn chia chuyến đi này thành bao nhiêu chặng nhỏ? (Tối đa 20 chặng)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-6 mb-8">
|
||||||
|
<button
|
||||||
|
onClick={() => setTempLegCount(Math.max(1, tempLegCount - 1))}
|
||||||
|
className="w-12 h-12 rounded-2xl border-2 border-gray-100 flex items-center justify-center text-2xl font-bold text-gray-400 hover:border-blue-200 hover:text-blue-600 transition-all"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<span className="text-4xl font-black text-blue-600 w-12 text-center">{tempLegCount}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setTempLegCount(Math.min(20, tempLegCount + 1))}
|
||||||
|
className="w-12 h-12 rounded-2xl border-2 border-gray-100 flex items-center justify-center text-2xl font-bold text-gray-400 hover:border-blue-200 hover:text-blue-600 transition-all"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={confirmDeclareLegs}
|
||||||
|
className="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl shadow-lg shadow-blue-100 transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
Xác nhận
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal Chỉnh sửa Chặng (Popover) */}
|
||||||
|
{isEditModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-[4000] flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={() => setIsEditModalOpen(false)} />
|
||||||
|
<div className="relative w-full max-w-md bg-white rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h3 className="text-xl font-black text-gray-900">Chỉnh sửa Chặng</h3>
|
||||||
|
<button onClick={() => setIsEditModalOpen(false)} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||||
|
<X className="w-5 h-5 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Tên chặng</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingLegData.note}
|
||||||
|
onChange={(e) => setEditingLegData({ ...editingLegData, note: e.target.value })}
|
||||||
|
placeholder="VD: Ngày 1: Khởi hành"
|
||||||
|
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-2 text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">
|
||||||
|
<AlignLeft className="w-3 h-3" /> Mô tả chi tiết
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={editingLegData.description}
|
||||||
|
onChange={(e) => setEditingLegData({ ...editingLegData, description: e.target.value })}
|
||||||
|
placeholder="Mô tả các hoạt động chính trong chặng này..."
|
||||||
|
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm min-h-[120px] resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-2 text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">
|
||||||
|
<CalendarIcon className="w-3 h-3" /> Bắt đầu
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={editingLegData.startDate}
|
||||||
|
onChange={(e) => setEditingLegData({ ...editingLegData, startDate: e.target.value })}
|
||||||
|
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Kết thúc</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={editingLegData.endDate}
|
||||||
|
onChange={(e) => setEditingLegData({ ...editingLegData, endDate: e.target.value })}
|
||||||
|
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 mt-8">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditModalOpen(false)}
|
||||||
|
className="py-4 bg-gray-100 hover:bg-gray-200 text-gray-600 font-bold rounded-2xl transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
Hủy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={saveLegEdit}
|
||||||
|
className="py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl shadow-lg shadow-blue-100 transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
Lưu thay đổi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -22,8 +22,7 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const response = await fetch(`/api/v1/auth/login`, {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/auth/login`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
|
||||||
|
|
||||||
|
interface NotificationModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
type?: 'success' | 'error' | 'info';
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NotificationModal - Component hiển thị thông báo phản hồi cho người dùng
|
||||||
|
*/
|
||||||
|
export const NotificationModal: React.FC<NotificationModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
title = 'Thông báo',
|
||||||
|
message,
|
||||||
|
type = 'info',
|
||||||
|
onConfirm,
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
success: <CheckCircle className="w-12 h-12 text-green-500" />,
|
||||||
|
error: <AlertCircle className="w-12 h-12 text-red-500" />,
|
||||||
|
info: <Info className="w-12 h-12 text-blue-500" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
success: 'bg-green-600 hover:bg-green-700 shadow-green-100',
|
||||||
|
error: 'bg-red-600 hover:bg-red-700 shadow-red-100',
|
||||||
|
info: 'bg-blue-600 hover:bg-blue-700 shadow-blue-100',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[3000] flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onConfirm} />
|
||||||
|
|
||||||
|
{/* Modal Content */}
|
||||||
|
<div className="relative w-full max-w-sm bg-white rounded-[32px] shadow-2xl overflow-hidden p-8 text-center animate-in zoom-in-95 duration-200">
|
||||||
|
<div className="flex justify-center mb-5">
|
||||||
|
{icons[type]}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-black text-gray-900 mb-2">{title}</h2>
|
||||||
|
<p className="text-gray-500 text-sm leading-relaxed mb-8">
|
||||||
|
{message || "Bạn không được phép gỡ bỏ thành viên này!"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className={`w-full py-4 text-white font-bold rounded-2xl transition-all shadow-lg active:scale-95 ${colors[type]}`}
|
||||||
|
>
|
||||||
|
Đã hiểu
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook để quản lý trạng thái của NotificationModal
|
||||||
|
*/
|
||||||
|
export const useNotificationModal = () => {
|
||||||
|
const [modalState, setModalState] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
type?: 'success' | 'error' | 'info';
|
||||||
|
}>({
|
||||||
|
isOpen: false,
|
||||||
|
title: 'Thông báo',
|
||||||
|
message: '',
|
||||||
|
type: 'info',
|
||||||
|
});
|
||||||
|
|
||||||
|
const openModal = (title: string, message: string, type: 'success' | 'error' | 'info' = 'info') => {
|
||||||
|
setModalState({ isOpen: true, title, message, type });
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setModalState((prev) => ({ ...prev, isOpen: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return { modalState, openModal, closeModal };
|
||||||
|
};
|
||||||
@@ -14,8 +14,7 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
|
|||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const response = await fetch(`/api/v1/users`, {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/users`, {
|
|
||||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Không thể tải danh sách người dùng');
|
if (!response.ok) throw new Error('Không thể tải danh sách người dùng');
|
||||||
@@ -34,8 +33,7 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
|
|||||||
|
|
||||||
const handleToggleBlock = async (id: string) => {
|
const handleToggleBlock = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
await fetch(`/api/v1/users/block/${id}`, {
|
||||||
await fetch(`${API_BASE}/api/v1/users/block/${id}`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||||
});
|
});
|
||||||
@@ -48,8 +46,7 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
|
|||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!confirm('Bạn có chắc chắn muốn xóa người dùng này?')) return;
|
if (!confirm('Bạn có chắc chắn muốn xóa người dùng này?')) return;
|
||||||
try {
|
try {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const res = await fetch(`/api/v1/users/${id}`, {
|
||||||
const res = await fetch(`${API_BASE}/api/v1/users/${id}`, {
|
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||||
});
|
});
|
||||||
@@ -4,10 +4,10 @@ import _MarkerClusterGroup from 'react-leaflet-cluster';
|
|||||||
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { useTourStore } from './useTourStore.js';
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2 } from 'lucide-react';
|
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2 } from 'lucide-react';
|
||||||
import { UserManagementModal } from './UserManagementModal.js';
|
import { UserManagementModal } from '@/components/UserManagementModal';
|
||||||
import { CreateTourModal } from './CreateTourModal.js';
|
import { CreateTourModal } from '../components/CreateTourModal';
|
||||||
|
|
||||||
// Fix lỗi icon mặc định của Leaflet
|
// Fix lỗi icon mặc định của Leaflet
|
||||||
const DefaultIcon = L.icon({
|
const DefaultIcon = L.icon({
|
||||||
@@ -46,8 +46,11 @@ function MapTracker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void }) => {
|
export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void }) => {
|
||||||
// Thêm fetchTour vào destructuring từ store
|
// Tối ưu hóa việc lấy dữ liệu từ store bằng selectors để tránh re-render thừa
|
||||||
const { publicTours, fetchPublicTours, fetchTour, setMapCenter } = useTourStore();
|
const publicTours = useTourStore(state => state.publicTours);
|
||||||
|
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
|
||||||
|
const fetchTour = useTourStore(state => state.fetchTour);
|
||||||
|
const setMapCenter = useTourStore(state => state.setMapCenter);
|
||||||
|
|
||||||
// Khởi tạo vị trí từ localStorage nếu có, nếu không dùng mặc định (TP.HCM)
|
// Khởi tạo vị trí từ localStorage nếu có, nếu không dùng mặc định (TP.HCM)
|
||||||
const [initialViewState] = useState(() => {
|
const [initialViewState] = useState(() => {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { LogIn, Compass, ArrowRight, Map as MapIcon, UserPlus, ShieldCheck } from 'lucide-react';
|
import { LogIn, Compass, ArrowRight, Map as MapIcon, UserPlus, ShieldCheck } from 'lucide-react';
|
||||||
import { LoginModal } from './LoginModal.js';
|
import { LoginModal } from '../components/LoginModal';
|
||||||
|
|
||||||
const TRAVEL_IMAGES = [
|
const TRAVEL_IMAGES = [
|
||||||
"https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?auto=format&fit=crop&q=80",
|
"https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?auto=format&fit=crop&q=80",
|
||||||
@@ -29,9 +29,7 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const response = await fetch(`/api/v1/auth/signup`, {
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/api/v1/auth/signup`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { ItineraryTimeline } from './ItineraryTimeline.js';
|
import { ItineraryTimeline } from '../components/ItineraryTimeline';
|
||||||
import { ExpenseManager } from './ExpenseManager.js';
|
import { ExpenseManager } from '../components/ExpenseManager';
|
||||||
import { useTourStore } from './useTourStore.js';
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
import { AddLocationModal } from './AddLocationModal.js';
|
import { AddLocationModal } from '@/components/AddLocationModal';
|
||||||
import { AddMemberModal } from './AddMemberModal.js';
|
import { AddMemberModal } from '../components/AddMemberModal';
|
||||||
import { ConfirmModal } from './ConfirmModal.js';
|
import { ConfirmModal } from '../components/ConfirmModal';
|
||||||
import { NotificationModal, useNotificationModal } from './components/NotificationModal.js';
|
import { NotificationModal, useNotificationModal } from '@/components/NotificationModal';
|
||||||
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline, useMap } from 'react-leaflet';
|
||||||
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||||
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
||||||
import { useMap } from 'react-leaflet';
|
|
||||||
import {
|
import {
|
||||||
Map as MapIcon,
|
Map as MapIcon,
|
||||||
Wallet,
|
Wallet,
|
||||||
@@ -163,6 +162,13 @@ const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.Lat
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||||
|
// Tách biệt các state và actions để tối ưu performance - Chuyển lên đầu để tránh lỗi initialization
|
||||||
|
const currentTour = useTourStore(state => state.currentTour);
|
||||||
|
const legs = useTourStore(state => state.legs);
|
||||||
|
const publicTours = useTourStore(state => state.publicTours);
|
||||||
|
const userRole = useTourStore(state => state.userRole);
|
||||||
|
const mapCenter = useTourStore(state => state.mapCenter);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings'>('plan');
|
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings'>('plan');
|
||||||
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
|
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
|
||||||
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
|
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
|
||||||
@@ -172,15 +178,27 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
const [selectedMember, setSelectedMember] = useState<any>(null);
|
const [selectedMember, setSelectedMember] = useState<any>(null);
|
||||||
const [isMemberDetailOpen, setIsMemberDetailOpen] = useState(false);
|
const [isMemberDetailOpen, setIsMemberDetailOpen] = useState(false);
|
||||||
const [joinRequests, setJoinRequests] = useState<any[]>([]);
|
const [joinRequests, setJoinRequests] = useState<any[]>([]);
|
||||||
|
const [titleInput, setTitleInput] = useState(currentTour?.title ?? '');
|
||||||
|
const [descriptionInput, setDescriptionInput] = useState(currentTour?.description ?? '');
|
||||||
|
// State cho input số lượng người tham gia
|
||||||
|
const [adultCountInput, setAdultCountInput] = useState(currentTour?.adultCount ?? 0);
|
||||||
|
const [childCountInput, setChildCountInput] = useState(currentTour?.childCount ?? 0);
|
||||||
|
const [childDiscountInput, setChildDiscountInput] = useState(currentTour?.childDiscount ?? 0);
|
||||||
const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(null);
|
const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(null);
|
||||||
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
|
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
|
||||||
|
|
||||||
const {
|
const fetchTour = useTourStore(state => state.fetchTour);
|
||||||
currentTour, legs, fetchTour, fetchPublicTours, publicTours,
|
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
|
||||||
userRole, mapCenter, setMapCenter, updateTourStartPoint,
|
const setMapCenter = useTourStore(state => state.setMapCenter);
|
||||||
updateTourEndPoint, initializeLegs, addLocation, addMember, removeMember,
|
const updateTourStartPoint = useTourStore(state => state.updateTourStartPoint);
|
||||||
fetchJoinRequests, acceptJoinRequest, rejectJoinRequest
|
const updateTourEndPoint = useTourStore(state => state.updateTourEndPoint);
|
||||||
} = useTourStore();
|
const updateTourDetails = useTourStore(state => state.updateTourDetails); // Thêm action này
|
||||||
|
const initializeLegs = useTourStore(state => state.initializeLegs);
|
||||||
|
const addLocation = useTourStore(state => state.addLocation);
|
||||||
|
const removeMember = useTourStore(state => state.removeMember);
|
||||||
|
const fetchJoinRequests = useTourStore(state => state.fetchJoinRequests);
|
||||||
|
const acceptJoinRequest = useTourStore(state => state.acceptJoinRequest);
|
||||||
|
const rejectJoinRequest = useTourStore(state => state.rejectJoinRequest);
|
||||||
|
|
||||||
const notificationModal = useNotificationModal();
|
const notificationModal = useNotificationModal();
|
||||||
|
|
||||||
@@ -196,6 +214,8 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
// SỬA LỖI: Sử dụng các selectors riêng lẻ để tránh re-render trang khi mapCenter trong store thay đổi
|
// SỬA LỖI: Sử dụng các selectors riêng lẻ để tránh re-render trang khi mapCenter trong store thay đổi
|
||||||
|
|
||||||
const canEdit = ['OWNER', 'MANAGER'].includes(userRole || '');
|
const canEdit = ['OWNER', 'MANAGER'].includes(userRole || '');
|
||||||
|
const canInvite = ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
||||||
|
const isOwner = userRole === 'OWNER';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentTour && ['OWNER', 'MANAGER'].includes(userRole || '')) {
|
if (currentTour && ['OWNER', 'MANAGER'].includes(userRole || '')) {
|
||||||
@@ -228,14 +248,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
}
|
}
|
||||||
}, [publicTours, currentTour, fetchTour]);
|
}, [publicTours, currentTour, fetchTour]);
|
||||||
|
|
||||||
// Hàm xử lý khai báo số chặng
|
|
||||||
const handleDeclareLegs = async () => {
|
|
||||||
const countStr = window.prompt("Chuyến đi này bạn muốn chia làm bao nhiêu chặng?", "3");
|
|
||||||
const count = parseInt(countStr || "0");
|
|
||||||
if (count > 0 && currentTour) {
|
|
||||||
await initializeLegs(currentTour.id, count);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hàm xử lý các hành động từ Context Menu của bản đồ
|
// Hàm xử lý các hành động từ Context Menu của bản đồ
|
||||||
const handleMapAction = async (action: string, latlng: L.LatLng) => {
|
const handleMapAction = async (action: string, latlng: L.LatLng) => {
|
||||||
@@ -328,6 +341,23 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hàm xử lý cập nhật số lượng người tham gia
|
||||||
|
const handleUpdateTourInfo = async () => {
|
||||||
|
if (!currentTour) return;
|
||||||
|
try {
|
||||||
|
await updateTourDetails(currentTour.id, {
|
||||||
|
title: titleInput,
|
||||||
|
description: descriptionInput,
|
||||||
|
adultCount: adultCountInput,
|
||||||
|
childCount: childCountInput,
|
||||||
|
childDiscount: childDiscountInput,
|
||||||
|
});
|
||||||
|
notificationModal.openModal('Thành công', 'Đã cập nhật thông tin chuyến đi.', 'success');
|
||||||
|
fetchTour(currentTour.id); // Re-fetch tour để cập nhật lại các tính toán liên quan
|
||||||
|
} catch (error: any) {
|
||||||
|
notificationModal.openModal('Lỗi', error.message || 'Không thể cập nhật thông tin.', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
// Xác định Điểm xuất phát và Điểm kết thúc hiển thị dưới widget tài chính
|
// Xác định Điểm xuất phát và Điểm kết thúc hiển thị dưới widget tài chính
|
||||||
const startPoint = legs[0]?.locations[0];
|
const startPoint = legs[0]?.locations[0];
|
||||||
const lastLeg = legs[legs.length - 1];
|
const lastLeg = legs[legs.length - 1];
|
||||||
@@ -343,6 +373,23 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
|
|
||||||
const hasFinanceAccess = ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
const hasFinanceAccess = ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
||||||
|
|
||||||
|
// Logic tính toán ngày hiển thị: Ưu tiên ngày của Tour, sau đó đến ngày của các Chặng
|
||||||
|
const tourDateDisplay = useMemo(() => {
|
||||||
|
if (currentTour?.startDate && currentTour?.endDate) {
|
||||||
|
return `${new Date(currentTour.startDate).toLocaleDateString('vi-VN')} - ${new Date(currentTour.endDate).toLocaleDateString('vi-VN')}`;
|
||||||
|
}
|
||||||
|
const firstLeg = legs[0];
|
||||||
|
const lastLeg = legs[legs.length - 1];
|
||||||
|
const start = firstLeg?.startDate;
|
||||||
|
const end = lastLeg?.endDate || lastLeg?.startDate;
|
||||||
|
if (start && end) {
|
||||||
|
return `${new Date(start).toLocaleDateString('vi-VN')} - ${new Date(end).toLocaleDateString('vi-VN')}`;
|
||||||
|
} else if (start) {
|
||||||
|
return `Từ ${new Date(start).toLocaleDateString('vi-VN')}`;
|
||||||
|
}
|
||||||
|
return "Chưa xác định ngày";
|
||||||
|
}, [currentTour, legs]);
|
||||||
|
|
||||||
const travelQuotes = [
|
const travelQuotes = [
|
||||||
"Đừng nghe họ nói, hãy tự mình đi xem.",
|
"Đừ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.",
|
"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.",
|
||||||
@@ -354,7 +401,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
|
|
||||||
const tourInfo = {
|
const tourInfo = {
|
||||||
title: currentTour?.title || "Hành trình khám phá TP.HCM",
|
title: currentTour?.title || "Hành trình khám phá TP.HCM",
|
||||||
date: currentTour?.startDate ? `${new Date(currentTour.startDate).toLocaleDateString('vi-VN')} - ${new Date(currentTour.endDate).toLocaleDateString('vi-VN')}` : "Chưa xác định ngày",
|
date: tourDateDisplay,
|
||||||
membersCount: currentTour?.participants?.length || 0,
|
membersCount: currentTour?.participants?.length || 0,
|
||||||
budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND",
|
budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND",
|
||||||
coverImage: currentTour?.photos?.[0]?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
|
coverImage: currentTour?.photos?.[0]?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
|
||||||
@@ -385,6 +432,14 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
<div className="relative z-10 p-6 text-white pt-28 pb-20">
|
<div className="relative z-10 p-6 text-white pt-28 pb-20">
|
||||||
<div className="max-w-2xl mx-auto space-y-4">
|
<div className="max-w-2xl mx-auto space-y-4">
|
||||||
<h2 className="text-3xl font-black tracking-tight drop-shadow-md">{tourInfo.title}</h2>
|
<h2 className="text-3xl font-black tracking-tight drop-shadow-md">{tourInfo.title}</h2>
|
||||||
|
|
||||||
|
{currentTour?.description && (
|
||||||
|
<p className="text-sm md:text-base text-white/90 max-w-xl line-clamp-3 md:line-clamp-none bg-black/20 backdrop-blur-sm p-4 rounded-2xl border border-white/10 italic leading-relaxed">
|
||||||
|
<Quote className="w-4 h-4 inline-block mr-2 opacity-50" />
|
||||||
|
{currentTour.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Dòng tóm tắt Lộ trình */}
|
{/* Dòng tóm tắt Lộ trình */}
|
||||||
<div className="mt-3 text-[11px] sm:text-sm font-bold text-white bg-black/40 backdrop-blur-md px-4 py-2 rounded-xl border border-white/10 inline-flex items-center max-w-full">
|
<div className="mt-3 text-[11px] sm:text-sm font-bold text-white bg-black/40 backdrop-blur-md px-4 py-2 rounded-xl border border-white/10 inline-flex items-center max-w-full">
|
||||||
<span className="text-white/60 mr-1">Lộ trình:</span>
|
<span className="text-white/60 mr-1">Lộ trình:</span>
|
||||||
@@ -424,7 +479,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
<img src={`https://i.pravatar.cc/100?u=${p.userId}`} alt="Avatar" />
|
<img src={`https://i.pravatar.cc/100?u=${p.userId}`} alt="Avatar" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{joinRequests.slice(0, 3).map((req: any) => (
|
{isOwner && joinRequests.slice(0, 3).map((req: any) => (
|
||||||
<div key={req.id} className="relative group">
|
<div key={req.id} className="relative group">
|
||||||
<div className="w-10 h-10 rounded-full border-2 border-amber-400 bg-amber-50 flex items-center justify-center text-amber-700 font-bold overflow-hidden shadow-lg">
|
<div className="w-10 h-10 rounded-full border-2 border-amber-400 bg-amber-50 flex items-center justify-center text-amber-700 font-bold overflow-hidden shadow-lg">
|
||||||
{req.user?.name?.charAt(0) || '?'}
|
{req.user?.name?.charAt(0) || '?'}
|
||||||
@@ -500,11 +555,11 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!currentTour) return;
|
if (!currentTour) return;
|
||||||
if (canEdit) setIsAddMemberOpen(true);
|
if (canInvite) setIsAddMemberOpen(true);
|
||||||
}}
|
}}
|
||||||
disabled={!canEdit}
|
disabled={!canInvite}
|
||||||
className={`p-2 rounded-full border backdrop-blur-sm transition-all ml-2 ${
|
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'
|
canInvite ? 'bg-white/10 hover:bg-white/20 border-white/20' : 'bg-white/5 border-white/10 opacity-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
@@ -516,12 +571,15 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
|
|
||||||
{/* Financial Quick-View Widget or Quote */}
|
{/* Financial Quick-View Widget or Quote */}
|
||||||
<div className="max-w-2xl mx-auto -mt-10 px-4 relative z-10">
|
<div className="max-w-2xl mx-auto -mt-10 px-4 relative z-10">
|
||||||
<div className={`rounded-3xl p-6 shadow-2xl transition-all duration-500 ${hasFinanceAccess ? 'bg-indigo-600 text-white shadow-indigo-200' : 'bg-white text-gray-600 border border-gray-100'}`}>
|
<div
|
||||||
|
onClick={() => hasFinanceAccess && setActiveTab('expense')}
|
||||||
|
className={`rounded-3xl p-6 shadow-2xl transition-all duration-500 ${hasFinanceAccess ? 'bg-indigo-600 text-white shadow-indigo-200 cursor-pointer hover:scale-[1.02] active:scale-95' : 'bg-white text-gray-600 border border-gray-100'}`}
|
||||||
|
>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
{hasFinanceAccess ? (
|
{hasFinanceAccess ? (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-indigo-100 text-xs font-black uppercase tracking-widest mb-1">Tổng chi tiêu hiện tại</p>
|
<p className="text-indigo-100 text-[10px] font-black uppercase tracking-widest mb-1">Tổng chi tiêu hiện tại (Nhấn để xem chi tiết)</p>
|
||||||
<h3 className="text-3xl font-black">{tourInfo.budget}</h3>
|
<h3 className="text-3xl font-black">{tourInfo.budget}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-white/10 rounded-2xl backdrop-blur-md"><Wallet className="w-8 h-8" /></div>
|
<div className="p-4 bg-white/10 rounded-2xl backdrop-blur-md"><Wallet className="w-8 h-8" /></div>
|
||||||
@@ -709,7 +767,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
{isOwner && <div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
disabled={joinRequestActionId === req.id}
|
disabled={joinRequestActionId === req.id}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -764,7 +822,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{joinRequests.length === 0 && (
|
{joinRequests.length === 0 && (
|
||||||
@@ -772,6 +830,89 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<div className="p-6 bg-white rounded-3xl border border-gray-100 shadow-sm animate-in zoom-in-95">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Settings className="w-6 h-6 text-blue-500" />
|
||||||
|
<h3 className="text-lg font-bold text-gray-900">Thông tin cơ bản</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 mb-8">
|
||||||
|
{isOwner && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Tiêu đề Tour</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={titleInput}
|
||||||
|
onChange={(e) => setTitleInput(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold"
|
||||||
|
placeholder="Nhập tên chuyến đi..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Mô tả chuyến đi</label>
|
||||||
|
<textarea
|
||||||
|
value={descriptionInput}
|
||||||
|
onChange={(e) => setDescriptionInput(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm min-h-[100px] resize-none"
|
||||||
|
placeholder="Viết vài dòng giới thiệu về hành trình này..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<Users className="w-6 h-6 text-purple-500" />
|
||||||
|
<h3 className="text-md font-bold text-gray-800">Số lượng người tham gia</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="adultCount" className="block text-sm font-medium text-gray-700 mb-1">Số lượng người lớn</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="adultCount"
|
||||||
|
value={adultCountInput}
|
||||||
|
onChange={(e) => setAdultCountInput(Number(e.target.value))}
|
||||||
|
min="0"
|
||||||
|
className="w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="childCount" className="block text-sm font-medium text-gray-700 mb-1">Số lượng trẻ em</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="childCount"
|
||||||
|
value={childCountInput}
|
||||||
|
onChange={(e) => setChildCountInput(Number(e.target.value))}
|
||||||
|
min="0"
|
||||||
|
className="w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="childDiscount" className="block text-sm font-medium text-gray-700 mb-1">Giảm giá trẻ em (%)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="childDiscount"
|
||||||
|
value={childDiscountInput}
|
||||||
|
onChange={(e) => setChildDiscountInput(Number(e.target.value))}
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
className="w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleUpdateTourInfo}
|
||||||
|
className="w-full mt-6 px-4 py-4 bg-blue-600 hover:bg-blue-700 text-white font-black uppercase tracking-widest rounded-2xl transition-all shadow-lg shadow-blue-100 active:scale-95"
|
||||||
|
>
|
||||||
|
Lưu thay đổi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="p-8 text-center bg-gray-50 rounded-3xl border border-dashed border-gray-200">
|
<div className="p-8 text-center bg-gray-50 rounded-3xl border border-dashed border-gray-200">
|
||||||
<Settings className="w-10 h-10 text-gray-300 mx-auto mb-3" />
|
<Settings className="w-10 h-10 text-gray-300 mx-auto mb-3" />
|
||||||
<p className="text-gray-500 font-medium">Tính năng cài đặt khác đang được cập nhật...</p>
|
<p className="text-gray-500 font-medium">Tính năng cài đặt khác đang được cập nhật...</p>
|
||||||
@@ -10,6 +10,7 @@ interface TourState {
|
|||||||
setTour: (tour: any) => void;
|
setTour: (tour: any) => void;
|
||||||
updateLegs: (legs: any[]) => void;
|
updateLegs: (legs: any[]) => void;
|
||||||
createTour: (tourData: any) => Promise<any>;
|
createTour: (tourData: any) => Promise<any>;
|
||||||
|
updateTourDetails: (tourId: string, data: any) => Promise<void>;
|
||||||
updateTour: (id: string, data: any) => Promise<void>;
|
updateTour: (id: string, data: any) => Promise<void>;
|
||||||
deleteTour: (id: string) => Promise<void>;
|
deleteTour: (id: string) => Promise<void>;
|
||||||
addLeg: (tourId: string, data: any) => Promise<void>;
|
addLeg: (tourId: string, data: any) => Promise<void>;
|
||||||
@@ -106,6 +107,23 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
await get().fetchPublicTours();
|
await get().fetchPublicTours();
|
||||||
return tour;
|
return tour;
|
||||||
},
|
},
|
||||||
|
updateTourDetails: async (tourId: string, data: any) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || errorData.error || 'Lỗi khi cập nhật thông tin tour');
|
||||||
|
}
|
||||||
|
},
|
||||||
updateTour: async (id: string, data: any) => {
|
updateTour: async (id: string, data: any) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
|
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Config } from 'tailwindcss';
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
'./index.html',
|
||||||
|
'./src/**/*.{js,ts,jsx,tsx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
"baseUrl": "./",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3002,
|
||||||
|
host: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+1475
-3024
File diff suppressed because it is too large
Load Diff
+18
-49
@@ -1,57 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "travel-planning-backend",
|
"name": "travel-planning-monorepo",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Travel Planning Backend with NestJS and Prisma 7",
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"workspaces": [
|
||||||
|
"backend",
|
||||||
|
"frontend"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "npm run build --workspace=backend && npm run build --workspace=frontend",
|
||||||
"start": "nest start",
|
"start:backend": "npm run start:dev --workspace=backend",
|
||||||
"start:dev": "nest start --watch",
|
"start:frontend": "npm run start:dev --workspace=frontend",
|
||||||
"frontend": "vite",
|
"start:dev": "concurrently -n \"BACKEND,FRONTEND\" -c \"magenta,cyan\" \"npm run start:dev --workspace=backend\" \"npm run dev --workspace=frontend\"",
|
||||||
"db:migrate": "npx prisma migrate dev",
|
"db:migrate": "npm run db:migrate --workspace=backend",
|
||||||
"db:generate": "npx prisma generate",
|
"db:generate": "npm run db:generate --workspace=backend",
|
||||||
"db:seed": "npx tsx seed.ts"
|
"db:seed": "npm run db:seed --workspace=backend"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@nestjs/common": "^11.1.26",
|
|
||||||
"@nestjs/core": "^11.1.26",
|
|
||||||
"@nestjs/jwt": "^11.0.2",
|
|
||||||
"@nestjs/passport": "^11.0.5",
|
|
||||||
"@nestjs/platform-express": "^11.1.26",
|
|
||||||
"@prisma/adapter-pg": "^7.8.0",
|
|
||||||
"@prisma/client": "^7.8.0",
|
|
||||||
"bcrypt": "^5.1.1",
|
|
||||||
"date-fns": "^2.30.0",
|
|
||||||
"dotenv": "^16.3.0",
|
|
||||||
"leaflet": "^1.9.4",
|
|
||||||
"lucide-react": "^0.284.0",
|
|
||||||
"passport": "^0.7.0",
|
|
||||||
"passport-jwt": "^4.0.1",
|
|
||||||
"pg": "^8.11.0",
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"react-leaflet": "^4.2.1",
|
|
||||||
"react-leaflet-cluster": "^2.1.0",
|
|
||||||
"reflect-metadata": "^0.1.13",
|
|
||||||
"rxjs": "^7.8.1",
|
|
||||||
"zustand": "^5.0.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.0.0",
|
"concurrently": "^8.2.2"
|
||||||
"@tailwindcss/postcss": "^4.3.1",
|
},
|
||||||
"@types/bcrypt": "^5.0.2",
|
"dependencies": {
|
||||||
"@types/leaflet": "^1.9.12",
|
"jspdf": "^4.2.1",
|
||||||
"@types/node": "^20.0.0",
|
"jspdf-autotable": "^5.0.8"
|
||||||
"@types/passport-jwt": "^4.0.1",
|
|
||||||
"@types/pg": "^8.10.0",
|
|
||||||
"@types/react": "^18.3.12",
|
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
|
||||||
"autoprefixer": "^10.5.0",
|
|
||||||
"postcss": "^8.5.15",
|
|
||||||
"prisma": "^7.8.0",
|
|
||||||
"tailwindcss": "^4.3.1",
|
|
||||||
"tsx": "^4.0.0",
|
|
||||||
"typescript": "^5.7.0",
|
|
||||||
"vite": "^8.0.16"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
# Please do not edit this file manually
|
|
||||||
# It should be added in your version-control system (e.g., Git)
|
|
||||||
provider = "postgresql"
|
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Ensure the script exits if any command fails
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Starting monorepo refactoring..."
|
||||||
|
|
||||||
|
# --- 1. Create new directories ---
|
||||||
|
echo "Creating new directories..."
|
||||||
|
|
||||||
|
mkdir -p backend/src/auth
|
||||||
|
mkdir -p backend/src/common
|
||||||
|
mkdir -p backend/deprecated-migrations
|
||||||
|
mkdir -p frontend/src/components
|
||||||
|
mkdir -p frontend/src/pages
|
||||||
|
mkdir -p frontend/src/store
|
||||||
|
mkdir -p docs
|
||||||
|
|
||||||
|
echo "Directories created."
|
||||||
|
|
||||||
|
# --- 2. Move files to backend/ ---
|
||||||
|
echo "Moving backend files..."
|
||||||
|
|
||||||
|
mv admin.guard.ts backend/src/auth/
|
||||||
|
mv jwt-auth.guard.ts backend/src/auth/
|
||||||
|
mv jwt.strategy.ts backend/src/auth/
|
||||||
|
mv rbac.middleware.ts backend/src/common/
|
||||||
|
mv main.ts backend/src/
|
||||||
|
mv nest-cli.json backend/
|
||||||
|
mv tsconfig.json backend/
|
||||||
|
mv tsconfig.build.json backend/
|
||||||
|
mv seed.ts backend/
|
||||||
|
|
||||||
|
# Special handling for prisma directory and prisma.service.ts
|
||||||
|
# First, move the existing prisma directory to backend/
|
||||||
|
mv prisma backend/
|
||||||
|
|
||||||
|
# Then, move prisma.service.ts into the newly moved backend/prisma directory
|
||||||
|
mv prisma.service.ts backend/prisma/
|
||||||
|
|
||||||
|
# Move old migrations (not Prisma's) to deprecated-migrations
|
||||||
|
if [ -d "migrations" ]; then
|
||||||
|
mv migrations backend/deprecated-migrations/
|
||||||
|
else
|
||||||
|
echo "Warning: 'migrations' directory not found at root. Skipping move."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Move schema.sql if it exists and is related to backend
|
||||||
|
if [ -f "schema.sql" ]; then
|
||||||
|
mv schema.sql backend/prisma/
|
||||||
|
else
|
||||||
|
echo "Warning: 'schema.sql' not found at root. Skipping move."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Backend files moved."
|
||||||
|
|
||||||
|
# --- 3. Move files to frontend/ ---
|
||||||
|
echo "Moving frontend files..."
|
||||||
|
|
||||||
|
mv App.tsx frontend/src/
|
||||||
|
mv CreateTourModal.tsx frontend/src/components/
|
||||||
|
mv ExpenseManager.tsx frontend/src/components/ # Assuming this is a component
|
||||||
|
mv ExploreMap.tsx frontend/src/pages/ # Assuming this is a page
|
||||||
|
mv index.tsx frontend/src/
|
||||||
|
mv ItineraryTimeline.tsx frontend/src/components/ # Assuming this is a component
|
||||||
|
mv LandingPage.tsx frontend/src/pages/
|
||||||
|
mv LoginModal.tsx frontend/src/components/
|
||||||
|
mv SignupPage.tsx frontend/src/pages/
|
||||||
|
mv TourDetailPage.tsx frontend/src/pages/
|
||||||
|
mv useTourStore.ts frontend/src/store/
|
||||||
|
mv vite.config.ts frontend/
|
||||||
|
mv tailwind.config.ts frontend/
|
||||||
|
mv postcss.config.js frontend/
|
||||||
|
mv index.css frontend/src/
|
||||||
|
|
||||||
|
echo "Frontend files moved."
|
||||||
|
|
||||||
|
# --- 4. Move files to docs/ ---
|
||||||
|
echo "Moving documentation files..."
|
||||||
|
|
||||||
|
mv ARCHITECTURE.md docs/
|
||||||
|
mv UITourDesign.md docs/
|
||||||
|
|
||||||
|
echo "Documentation files moved."
|
||||||
|
|
||||||
|
echo "Monorepo refactoring complete. Please verify the new structure."
|
||||||
|
echo "Next, you will need to update import paths and configuration files."
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import type { Config } from 'tailwindcss'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
content: [
|
|
||||||
"./index.html",
|
|
||||||
"./*.{js,ts,jsx,tsx}",
|
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
} satisfies Config
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"ignoreDeprecations": "5.0",
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"rewriteRelativeImportExtensions": true,
|
|
||||||
"noEmit": false,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"declaration": true,
|
|
||||||
"removeComments": true,
|
|
||||||
"emitDecoratorMetadata": true,
|
|
||||||
"experimentalDecorators": true,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"target": "ES2021",
|
|
||||||
"sourceMap": true,
|
|
||||||
"outDir": "./dist",
|
|
||||||
"incremental": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"strictNullChecks": false,
|
|
||||||
"noImplicitAny": false,
|
|
||||||
"strictBindCallApply": false,
|
|
||||||
"forceConsistentCasingInFileNames": false,
|
|
||||||
"noFallthroughCasesInSwitch": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Executable
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Đảm bảo script dừng nếu có lỗi xảy ra
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🚀 Đang quét và cập nhật các đường dẫn import cho cấu trúc Monorepo..."
|
||||||
|
|
||||||
|
# --- 1. CẬP NHẬT BACKEND (NestJS) ---
|
||||||
|
|
||||||
|
# Cập nhật backend/src/main.ts
|
||||||
|
# Các Guard/Middleware nay nằm trong auth/ và common/
|
||||||
|
sed -i -E "s|from\s+['\"](\./)(admin\.guard\|jwt-auth\.guard\|jwt\.strategy)['\"]|from './auth/\2'|g" backend/src/main.ts
|
||||||
|
sed -i -E "s|from\s+['\"](\./)(rbac\.middleware)['\"]|from './common/\2'|g" backend/src/main.ts
|
||||||
|
# Đảm bảo Prisma service trỏ đúng về thư mục prisma ở root từ backend/src/
|
||||||
|
sed -i -E "s|from\s+['\"](\.\./)+(prisma/prisma\.service)['\"]|from '../../\2'|g" backend/src/main.ts
|
||||||
|
|
||||||
|
# Cập nhật các file trong backend/src/auth/ (Guards & Strategies)
|
||||||
|
# Từ backend/src/auth/ cần lùi 3 cấp để ra root prisma/
|
||||||
|
find backend/src/auth -name "*.ts" -exec sed -i -E "s|from\s+['\"](\.\./)+(prisma/prisma\.service)['\"]|from '../../../\2'|g" {} +
|
||||||
|
find backend/src/common -name "*.ts" -exec sed -i -E "s|from\s+['\"](\.\./)+(prisma/prisma\.service)['\"]|from '../../../\2'|g" {} +
|
||||||
|
|
||||||
|
|
||||||
|
# --- 2. CẬP NHẬT FRONTEND (React + Vite) ---
|
||||||
|
|
||||||
|
# Cập nhật App.tsx (frontend/src/App.tsx)
|
||||||
|
# Trỏ vào thư mục pages/ và store/
|
||||||
|
sed -i -E "s|from\s+['\"](\./)(LandingPage\|SignupPage\|TourDetailPage\|ExploreMap)['\"]|from './pages/\2'|g" frontend/src/App.tsx
|
||||||
|
sed -i -E "s|from\s+['\"](\./)(useTourStore)['\"]|from './store/\2'|g" frontend/src/App.tsx
|
||||||
|
|
||||||
|
# Cập nhật các Trang (frontend/src/pages/*.tsx)
|
||||||
|
# Các trang cần trỏ tới components/ và store/ nằm ở thư mục anh em
|
||||||
|
# Bao gồm: CreateTourModal, LoginModal, ExpenseManager, ItineraryTimeline, AddMemberModal, ConfirmModal, NotificationModal
|
||||||
|
find frontend/src/pages -name "*.tsx" -exec sed -i -E "s|from\s+['\"](\./\|@/components/)(CreateTourModal\|LoginModal\|ExpenseManager\|ItineraryTimeline\|AddMemberModal\|ConfirmModal\|NotificationModal)['\"]|from '../components/\2'|g" {} +
|
||||||
|
find frontend/src/pages -name "*.tsx" -exec sed -i -E "s|from\s+['\"](\./)(useTourStore)['\"]|from '../store/\2'|g" {} +
|
||||||
|
find frontend/src/pages -name "*.tsx" -exec sed -i -E "s|from\s+['\"](\./index\.css)['\"]|from '../index.css'|g" {} +
|
||||||
|
|
||||||
|
# Cập nhật các Component (frontend/src/components/*.tsx)
|
||||||
|
# Các component/modal cần trỏ tới store/
|
||||||
|
find frontend/src/components -name "*.tsx" -exec sed -i -E "s|from\s+['\"](\./)(useTourStore)['\"]|from '../store/\2'|g" {} +
|
||||||
|
|
||||||
|
echo "✅ Hoàn tất cập nhật Import Paths."
|
||||||
|
echo "💡 Lưu ý: Hãy kiểm tra lại các lỗi đỏ trong VS Code. Nếu bạn có sử dụng Path Alias (như @/components), chúng ta sẽ cấu hình nó ở bước tiếp theo trong tsconfig.json."
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
server: {
|
|
||||||
port: 3002, // Thay đổi cổng thành 5173 (mặc định của Vite) hoặc cổng khác bạn muốn
|
|
||||||
strictPort: true,
|
|
||||||
host: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user