10 Commits

20 changed files with 1100 additions and 131 deletions
-39
View File
@@ -1,43 +1,10 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
@@ -47,14 +14,8 @@ 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");
const dotenv = __importStar(require("dotenv"));
const path = __importStar(require("path"));
dotenv.config({ path: path.join(__dirname, '..', '.env') });
let PrismaService = class PrismaService extends client_1.PrismaClient {
constructor() {
if (!process.env.DATABASE_URL) {
dotenv.config({ path: path.join(__dirname, '..', '..', '.env') });
}
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('----------------------');
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"prisma.service.js","sourceRoot":"","sources":["../../prisma/prisma.service.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,2CAA2E;AAC3E,2CAA8C;AAC9C,mDAA8C;AAC9C,2BAA0B;AAC1B,+CAAiC;AACjC,2CAA6B;AAE7B,MAAM,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC;AAGrD,IAAM,aAAa,GAAnB,MAAM,aAAc,SAAQ,qBAAY;IAC7C;QACE,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC;YAC9B,MAAM,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC;QACpE,CAAC;QACD,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;AAfY,sCAAa;wBAAb,aAAa;IADzB,IAAA,mBAAU,GAAE;;GACA,aAAa,CAezB"}
{"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"}
+13 -3
View File
@@ -159,12 +159,15 @@ let TourController = class TourController {
this.prisma = prisma;
}
async createTour(body, req) {
const { title, startDate, endDate } = body;
const { title, startDate, endDate, adultCount, childCount, childDiscount } = body;
return this.prisma.tour.create({
data: {
title,
startDate: startDate ? new Date(startDate) : null,
endDate: endDate ? new Date(endDate) : null,
adultCount: adultCount || 1,
childCount: childCount || 0,
childDiscount: childDiscount || 0,
createdById: req.user.id,
participants: {
create: {
@@ -327,8 +330,12 @@ let TourController = class TourController {
where: { id },
data: {
title: body.title,
description: body.description,
startDate: body.startDate ? new Date(body.startDate) : 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,
},
});
}
@@ -517,7 +524,7 @@ let TourController = class TourController {
});
role = participation?.role;
}
if (!role || !(role === 'OWNER' || role === 'MANAGER')) {
if (!role || role !== 'OWNER') {
throw new common_1.ForbiddenException('Bạn không có quyền từ chối yêu cầu tham gia.');
}
const joinRequest = await this.prisma.joinRequest.findUnique({
@@ -778,7 +785,10 @@ let LegController = class LegController {
where: { id },
data: {
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,
}
});
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -3,14 +3,15 @@
"version": "0.0.1",
"scripts": {
"start:dev": "nest start --watch",
"db:generate": "prisma generate --schema=prisma/schema.prisma",
"db:migrate": "prisma migrate dev --schema=prisma/schema.prisma",
"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"
@@ -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;
@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
-7
View File
@@ -2,17 +2,10 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.join(__dirname, '..', '.env') });
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() {
if (!process.env.DATABASE_URL) {
dotenv.config({ path: path.join(__dirname, '..', '..', '.env') });
}
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('----------------------');
+8
View File
@@ -78,11 +78,16 @@ model User {
model Tour {
id String @id @default(uuid())
title String
description String?
startDate DateTime?
endDate DateTime?
createdAt DateTime @default(now())
totalCost Decimal @default(0) @db.Decimal(15, 2)
adultCount Int @default(1)
childCount Int @default(0)
childDiscount Int @default(30)
createdById String
creator User @relation("TourCreator", fields: [createdById], references: [id])
@@ -124,6 +129,9 @@ model Leg {
tourId String
sequence Int
note String? @db.Text
startDate DateTime?
endDate DateTime?
description String? @db.Text
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
locations Location[]
+14 -4
View File
@@ -108,12 +108,15 @@ class TourController {
@UseGuards(JwtAuthGuard)
@Post()
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({
data: {
title,
startDate: startDate ? new Date(startDate) : null,
endDate: endDate ? new Date(endDate) : null,
adultCount: adultCount || 1,
childCount: childCount || 0,
childDiscount: childDiscount || 0,
createdById: req.user.id,
participants: {
create: {
@@ -321,8 +324,12 @@ class TourController {
where: { id },
data: {
title: body.title,
description: body.description,
startDate: body.startDate ? new Date(body.startDate) : 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,
},
});
}
@@ -497,7 +504,7 @@ class TourController {
});
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.');
}
@@ -549,7 +556,7 @@ class TourController {
});
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.');
}
@@ -655,7 +662,10 @@ class LegController {
where: { id },
data: {
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,
}
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

+38 -2
View File
@@ -1,11 +1,14 @@
import React, { useState } from 'react';
import { Trash2 } from 'lucide-react';
import { Trash2, Users } from 'lucide-react';
import { useTourStore } from '@/store/useTourStore';
export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolean, onClose: () => void, onSuccess: (tour: any) => void }) => {
const [title, setTitle] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [adultCount, setAdultCount] = useState(2);
const [childCount, setChildCount] = useState(1);
const [childDiscount, setChildDiscount] = useState(30);
const [isLoading, setIsLoading] = useState(false);
const createTour = useTourStore((state) => state.createTour);
@@ -52,7 +55,15 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
setError('');
try {
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);
onClose();
} catch (e: any) {
@@ -104,6 +115,31 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
</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ấ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>
<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">
File diff suppressed because one or more lines are too long
+161 -9
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { format, differenceInMinutes, parseISO } from 'date-fns';
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List } from 'lucide-react';
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft } from 'lucide-react';
import { useTourStore } from '@/store/useTourStore';
import { ConfirmModal } from '@/components/ConfirmModal';
@@ -52,6 +52,18 @@ export const ItineraryTimeline = ({
const deleteLocation = useTourStore(state => state.deleteLocation);
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) => {
// Gọi API PATCH /api/v1/locations/:id để cập nhật trạng thái
@@ -66,17 +78,37 @@ export const ItineraryTimeline = ({
};
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);
setTempLegCount(legs.length > 0 ? legs.length : 3);
setIsLegCountModalOpen(true);
};
const confirmDeclareLegs = async () => {
if (tempLegCount > 0 && tempLegCount <= 20 && currentTour) {
await initializeLegs(currentTour.id, tempLegCount);
}
setIsLegCountModalOpen(false);
};
const handleEditLeg = async (leg: any) => {
const note = window.prompt("Sửa ghi chú chặng:", leg.note || "");
if (note !== null) {
await updateLeg(leg.id, { note });
setEditingLegData({
id: leg.id,
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);
}
};
@@ -371,6 +403,126 @@ export const ItineraryTimeline = ({
onConfirm={() => confirmState.onConfirm?.()}
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" /> 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>
);
};
+155 -25
View File
@@ -5,7 +5,7 @@ import { useTourStore } from '@/store/useTourStore';
import { AddLocationModal } from '@/components/AddLocationModal';
import { AddMemberModal } from '../components/AddMemberModal';
import { ConfirmModal } from '../components/ConfirmModal';
import { NotificationModal, useNotificationModal } from '../components/NotificationModal';
import { NotificationModal, useNotificationModal } from '@/components/NotificationModal';
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline, useMap } from 'react-leaflet';
import _MarkerClusterGroup from 'react-leaflet-cluster';
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
@@ -162,6 +162,13 @@ const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.Lat
};
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 [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
@@ -171,21 +178,21 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
const [selectedMember, setSelectedMember] = useState<any>(null);
const [isMemberDetailOpen, setIsMemberDetailOpen] = useState(false);
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 [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
// Tách biệt các state và actions để tối ưu performance
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 fetchTour = useTourStore(state => state.fetchTour);
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
const setMapCenter = useTourStore(state => state.setMapCenter);
const updateTourStartPoint = useTourStore(state => state.updateTourStartPoint);
const updateTourEndPoint = useTourStore(state => state.updateTourEndPoint);
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);
@@ -207,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
const canEdit = ['OWNER', 'MANAGER'].includes(userRole || '');
const canInvite = ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
const isOwner = userRole === 'OWNER';
useEffect(() => {
if (currentTour && ['OWNER', 'MANAGER'].includes(userRole || '')) {
@@ -239,14 +248,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
}
}, [publicTours, currentTour, fetchTour]);
// Hàm xử lý khai báo số chặng
const handleDeclareLegs = async () => {
const countStr = window.prompt("Chuyến đi này bạn muốn chia làm bao nhiêu chặng?", "3");
const count = parseInt(countStr || "0");
if (count > 0 && currentTour) {
await initializeLegs(currentTour.id, count);
}
};
// Hàm xử lý các hành động từ Context Menu của bản đồ
const handleMapAction = async (action: string, latlng: L.LatLng) => {
@@ -339,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
const startPoint = legs[0]?.locations[0];
const lastLeg = legs[legs.length - 1];
@@ -354,6 +373,23 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
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 = [
"Đừ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.",
@@ -365,7 +401,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
const tourInfo = {
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,
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"
@@ -396,6 +432,14 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
<div className="relative z-10 p-6 text-white pt-28 pb-20">
<div className="max-w-2xl mx-auto space-y-4">
<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 */}
<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>
@@ -435,7 +479,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
<img src={`https://i.pravatar.cc/100?u=${p.userId}`} alt="Avatar" />
</button>
))}
{joinRequests.slice(0, 3).map((req: any) => (
{isOwner && joinRequests.slice(0, 3).map((req: any) => (
<div key={req.id} className="relative group">
<div className="w-10 h-10 rounded-full border-2 border-amber-400 bg-amber-50 flex items-center justify-center text-amber-700 font-bold overflow-hidden shadow-lg">
{req.user?.name?.charAt(0) || '?'}
@@ -511,11 +555,11 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
<button
onClick={() => {
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 ${
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" />
@@ -527,12 +571,15 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
{/* Financial Quick-View Widget or Quote */}
<div className="max-w-2xl mx-auto -mt-10 px-4 relative z-10">
<div className={`rounded-3xl p-6 shadow-2xl transition-all duration-500 ${hasFinanceAccess ? 'bg-indigo-600 text-white shadow-indigo-200' : 'bg-white text-gray-600 border border-gray-100'}`}>
<div
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">
{hasFinanceAccess ? (
<>
<div>
<p className="text-indigo-100 text-xs font-black uppercase tracking-widest mb-1">Tổng chi tiêu hiện tại</p>
<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>
</div>
<div className="p-4 bg-white/10 rounded-2xl backdrop-blur-md"><Wallet className="w-8 h-8" /></div>
@@ -720,7 +767,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
</div>
</div>
</div>
<div className="flex gap-2">
{isOwner && <div className="flex gap-2">
<button
disabled={joinRequestActionId === req.id}
onClick={async () => {
@@ -775,7 +822,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
>
<X className="w-4 h-4" />
</button>
</div>
</div>}
</div>
))}
{joinRequests.length === 0 && (
@@ -783,6 +830,89 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
)}
</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 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"> 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">
<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>
+18
View File
@@ -10,6 +10,7 @@ interface TourState {
setTour: (tour: any) => void;
updateLegs: (legs: any[]) => void;
createTour: (tourData: any) => Promise<any>;
updateTourDetails: (tourId: string, data: any) => Promise<void>;
updateTour: (id: string, data: any) => Promise<void>;
deleteTour: (id: string) => Promise<void>;
addLeg: (tourId: string, data: any) => Promise<void>;
@@ -106,6 +107,23 @@ export const useTourStore = create<TourState>((set, get) => ({
await get().fetchPublicTours();
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) => {
const API_BASE = `http://${window.location.hostname}:3001`;
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
+340 -1
View File
@@ -11,6 +11,10 @@
"backend",
"frontend"
],
"dependencies": {
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8"
},
"devDependencies": {
"concurrently": "^8.2.2"
}
@@ -37,6 +41,7 @@
"@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"
@@ -372,7 +377,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -1856,6 +1860,12 @@
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
@@ -1875,6 +1885,13 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/react": {
"version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
@@ -1886,6 +1903,13 @@
"csstype": "^3.2.2"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@vitejs/plugin-react": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz",
@@ -2308,6 +2332,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -2554,6 +2588,26 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2828,6 +2882,18 @@
"node": ">=6.6.0"
}
},
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -2879,6 +2945,31 @@
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2972,6 +3063,16 @@
"node": ">=0.3.1"
}
},
"node_modules/dompurify": {
"version": "3.4.10",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz",
"integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
@@ -2984,6 +3085,45 @@
"url": "https://dotenvx.com"
}
},
"node_modules/dotenv-cli": {
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-7.4.4.tgz",
"integrity": "sha512-XkBYCG0tPIes+YZr4SpfFv76SQrV/LeCE8CI7JSEMi3VR9MvTihCGTOtbIexD6i2mXF+6px7trb1imVCXSNMDw==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.6",
"dotenv": "^16.3.0",
"dotenv-expand": "^10.0.0",
"minimist": "^1.2.6"
},
"bin": {
"dotenv": "cli.js"
}
},
"node_modules/dotenv-cli/node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dotenv-expand": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz",
"integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3250,6 +3390,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
@@ -3291,6 +3442,12 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-type": {
"version": "21.3.4",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
@@ -3604,6 +3761,20 @@
"node": ">= 0.4"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -3683,6 +3854,12 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -3738,6 +3915,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/iterare": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz",
@@ -3870,6 +4054,32 @@
"npm": ">=6"
}
},
"node_modules/jspdf": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/jspdf-autotable": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.8.tgz",
"integrity": "sha512-Hy05N86yBO7CXBrnSLOge7i1ZYpKH2DjQ94iybaP7vBhSInjvRBgDc99ngKzSbSO8Jc98ZCally8I6n0tj2RJQ==",
"license": "MIT",
"peerDependencies": {
"jspdf": "^2 || ^3 || ^4"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@ -4681,6 +4891,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -4758,6 +4974,16 @@
"node": ">= 0.4.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
@@ -4800,6 +5026,13 @@
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/pg": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
@@ -5032,6 +5265,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -5144,6 +5387,13 @@
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0"
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -5195,6 +5445,16 @@
"dev": true,
"license": "ISC"
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rolldown": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
@@ -5405,6 +5665,29 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/shell-quote": {
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
@@ -5559,6 +5842,16 @@
"node": ">= 10.x"
}
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -5655,6 +5948,16 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/symbol-observable": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz",
@@ -5811,6 +6114,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -6102,6 +6415,16 @@
"node": ">= 0.4.0"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
@@ -6325,6 +6648,22 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+4
View File
@@ -18,5 +18,9 @@
},
"devDependencies": {
"concurrently": "^8.2.2"
},
"dependencies": {
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8"
}
}