Cài đặt tính năng hiển thị chi phí
This commit is contained in:
Vendored
+4
-1
@@ -159,12 +159,15 @@ let TourController = class TourController {
|
|||||||
this.prisma = prisma;
|
this.prisma = prisma;
|
||||||
}
|
}
|
||||||
async createTour(body, req) {
|
async createTour(body, req) {
|
||||||
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: {
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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;
|
||||||
@@ -83,6 +83,10 @@ model Tour {
|
|||||||
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])
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -108,12 +108,15 @@ class TourController {
|
|||||||
@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: {
|
||||||
|
|||||||
@@ -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 '@/store/useTourStore';
|
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);
|
||||||
|
|
||||||
@@ -52,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) {
|
||||||
@@ -104,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">
|
||||||
|
|||||||
@@ -1,73 +1,297 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { Wallet, Users, Info } from 'lucide-react';
|
import { Wallet, Users, Info, PieChart, Tag, Calendar, User, FileText, TrendingUp, BarChart3 } from 'lucide-react';
|
||||||
import { useTourStore } from '@/store/useTourStore';
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
|
||||||
export const ExpenseManager = () => {
|
export const ExpenseManager = () => {
|
||||||
const { legs } = useTourStore();
|
const { legs, currentTour } = useTourStore();
|
||||||
const [adults, setAdults] = useState(2);
|
|
||||||
const [children, setChildren] = useState(1);
|
|
||||||
const [discount, setDiscount] = useState(30);
|
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
const adults = currentTour?.adultCount ?? 1;
|
||||||
const totalAmount = legs.reduce((acc, leg) =>
|
const children = currentTour?.childCount ?? 0;
|
||||||
acc + leg.expenses.reduce((lAcc: number, exp: any) => lAcc + Number(exp.amount), 0), 0
|
const discount = currentTour?.childDiscount ?? 0;
|
||||||
);
|
|
||||||
|
|
||||||
const childRateFactor = 1 - (discount / 100);
|
const stats = useMemo(() => {
|
||||||
|
const categoryMap: Record<string, number> = {
|
||||||
|
ACCOMMODATION: 0,
|
||||||
|
FOOD: 0,
|
||||||
|
TRANSPORT: 0,
|
||||||
|
TICKET: 0,
|
||||||
|
OTHER: 0
|
||||||
|
};
|
||||||
|
const legMap: Record<string, number> = {};
|
||||||
|
const memberMap: Record<string, number> = {};
|
||||||
|
let totalAmount = 0;
|
||||||
|
let totalCount = 0;
|
||||||
|
|
||||||
|
(legs || []).forEach(leg => {
|
||||||
|
let legTotal = 0;
|
||||||
|
(leg.expenses || []).forEach((exp: any) => {
|
||||||
|
const amt = Number(exp.amount);
|
||||||
|
totalAmount += amt;
|
||||||
|
legTotal += amt;
|
||||||
|
totalCount += 1;
|
||||||
|
|
||||||
|
const memberName = exp.paidBy?.name || 'Chưa rõ';
|
||||||
|
memberMap[memberName] = (memberMap[memberName] || 0) + amt;
|
||||||
|
|
||||||
|
if (categoryMap[exp.category] !== undefined) {
|
||||||
|
categoryMap[exp.category] += amt;
|
||||||
|
} else {
|
||||||
|
categoryMap.OTHER += amt;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
legMap[leg.sequence] = legTotal;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tìm hạng mục tốn nhất
|
||||||
|
const maxCat = Object.entries(categoryMap).reduce((a, b) => b[1] > a[1] ? b : a);
|
||||||
|
|
||||||
|
// Tìm chặng tốn nhất
|
||||||
|
const maxLegEntry = Object.entries(legMap).length > 0
|
||||||
|
? Object.entries(legMap).reduce((a, b) => b[1] > a[1] ? b : a)
|
||||||
|
: [null, 0];
|
||||||
|
|
||||||
|
const avgPerLeg = legs.length > 0 ? totalAmount / legs.length : 0;
|
||||||
|
|
||||||
|
// Danh sách phẳng tất cả chi phí để hiển thị bảng
|
||||||
|
const flatExpenses = (legs || []).flatMap(leg =>
|
||||||
|
(leg.expenses || []).map((exp: any) => ({
|
||||||
|
...exp,
|
||||||
|
legSequence: leg.sequence,
|
||||||
|
// Lấy ngày của Location nếu có, nếu không lấy ngày của Leg
|
||||||
|
date: exp.location?.plannedStart || leg.startDate || null
|
||||||
|
}))
|
||||||
|
).sort((a, b) => {
|
||||||
|
if (!a.date || !b.date) return 0;
|
||||||
|
return new Date(a.date).getTime() - new Date(b.date).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
const childRateFactor = 1 - (Number(discount) / 100);
|
||||||
const weightedCount = adults + (children * childRateFactor);
|
const weightedCount = adults + (children * childRateFactor);
|
||||||
const adultPrice = totalAmount / weightedCount;
|
const adultPrice = totalAmount / weightedCount;
|
||||||
const childPrice = adultPrice * childRateFactor;
|
const childPrice = adultPrice * childRateFactor;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total: totalAmount,
|
total: totalAmount,
|
||||||
|
count: totalCount,
|
||||||
adultPrice: Math.round(adultPrice),
|
adultPrice: Math.round(adultPrice),
|
||||||
childPrice: Math.round(childPrice)
|
childPrice: Math.round(childPrice),
|
||||||
|
categories: categoryMap,
|
||||||
|
list: flatExpenses,
|
||||||
|
topCategory: maxCat[0],
|
||||||
|
topLeg: maxLegEntry[0],
|
||||||
|
avgPerLeg,
|
||||||
|
memberStats: Object.entries(memberMap)
|
||||||
};
|
};
|
||||||
}, [legs, adults, children, discount]);
|
}, [legs, adults, children, discount]);
|
||||||
|
|
||||||
|
const categoryLabels: Record<string, { label: string, color: string }> = {
|
||||||
|
ACCOMMODATION: { label: 'Chỗ ở', color: 'bg-blue-500' },
|
||||||
|
FOOD: { label: 'Ăn uống', color: 'bg-orange-500' },
|
||||||
|
TRANSPORT: { label: 'Di chuyển', color: 'bg-indigo-500' },
|
||||||
|
TICKET: { label: 'Vé tham quan', color: 'bg-green-500' },
|
||||||
|
OTHER: { label: 'Khác', color: 'bg-gray-400' }
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4">
|
<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="bg-blue-600 rounded-2xl p-6 text-white shadow-lg shadow-blue-200">
|
||||||
<div className="flex justify-between items-start mb-8">
|
<div className="flex justify-between items-start mb-8">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-blue-100 text-sm">Tổng chi phí chuyến đi</p>
|
<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>
|
<h2 className="text-3xl font-bold mt-1">{stats.total.toLocaleString()} VND</h2>
|
||||||
|
<p className="text-blue-200 text-[10px] mt-1 font-bold uppercase tracking-wider">{stats.count} hóa đơn đã ghi nhận</p>
|
||||||
</div>
|
</div>
|
||||||
<Wallet className="w-8 h-8 opacity-20" />
|
<Wallet className="w-8 h-8 opacity-20" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4 border-t border-blue-500 pt-6">
|
<div className="grid grid-cols-2 gap-4 border-t border-blue-500 pt-6">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-blue-100 text-xs uppercase tracking-wider font-semibold">Mỗi người lớn</p>
|
<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>
|
<p className="text-xl font-bold">{stats.adultPrice.toLocaleString()}đ</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-blue-100 text-xs uppercase tracking-wider font-semibold">Mỗi trẻ em (-{discount}%)</p>
|
<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>
|
<p className="text-xl font-bold">{stats.childPrice.toLocaleString()}đ</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Các thẻ chỉ số nhanh */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-white p-4 rounded-2xl border border-gray-100 shadow-sm flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-orange-50 flex items-center justify-center text-orange-500">
|
||||||
|
<BarChart3 className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Hạng mục cao nhất</p>
|
||||||
|
<p className="text-sm font-bold text-gray-800">{categoryLabels[stats.topCategory]?.label || 'Chưa có'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-4 rounded-2xl border border-gray-100 shadow-sm flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-purple-50 flex items-center justify-center text-purple-500">
|
||||||
|
<TrendingUp className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Trung bình / Chặng</p>
|
||||||
|
<p className="text-sm font-bold text-gray-800">{Math.round(stats.avgPerLeg).toLocaleString()}đ</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-4 rounded-2xl border border-gray-100 shadow-sm flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-green-50 flex items-center justify-center text-green-500">
|
||||||
|
<Tag className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Chặng tốn kém nhất</p>
|
||||||
|
<p className="text-sm font-bold text-gray-800">{stats.topLeg ? `Chặng ${stats.topLeg}` : 'Chưa có'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thống kê theo thành viên */}
|
||||||
|
<div className="bg-white p-6 rounded-2xl border border-gray-100 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 mb-6 text-gray-800">
|
||||||
|
<Users className="w-5 h-5 text-blue-500" />
|
||||||
|
<h3 className="font-bold">Thống kê theo người chi trả</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{stats.memberStats.length > 0 ? stats.memberStats.map(([name, amount]) => {
|
||||||
|
const percentage = stats.total > 0 ? (amount / stats.total) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={name} className="flex items-center justify-between p-4 bg-gray-50 rounded-2xl border border-gray-100">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold text-xs">
|
||||||
|
{name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold text-gray-700">{name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm font-black text-gray-900">{amount.toLocaleString()}đ</div>
|
||||||
|
<div className="text-[10px] text-gray-400 font-bold uppercase">{percentage.toFixed(1)}% tổng chi</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}) : (
|
||||||
|
<div className="col-span-full py-8 text-center text-gray-400 text-sm italic">
|
||||||
|
Chưa có thông tin chi trả của thành viên.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thống kê theo hạng mục */}
|
||||||
|
<div className="bg-white p-6 rounded-2xl border border-gray-100 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 mb-6 text-gray-800">
|
||||||
|
<PieChart className="w-5 h-5 text-indigo-500" />
|
||||||
|
<h3 className="font-bold">Phân tích chi tiêu</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{stats.total > 0 ? Object.entries(stats.categories).map(([key, value]) => {
|
||||||
|
const percentage = stats.total > 0 ? (value / stats.total) * 100 : 0;
|
||||||
|
if (value === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
<div className="flex justify-between text-xs mb-1.5 font-bold">
|
||||||
|
<span className="text-gray-600 flex items-center gap-1.5">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${categoryLabels[key].color}`} />
|
||||||
|
{categoryLabels[key].label}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">{value.toLocaleString()}đ ({percentage.toFixed(1)}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 bg-gray-50 rounded-full overflow-hidden border border-gray-100">
|
||||||
|
<div className={`h-full ${categoryLabels[key].color} transition-all duration-500`} style={{ width: `${percentage}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}) : (
|
||||||
|
<div className="py-8 text-center text-gray-400 text-sm italic">
|
||||||
|
Chưa có dữ liệu phân tích theo hạng mục.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bảng thống kê chi tiết chi phí */}
|
||||||
|
<div className="bg-white rounded-3xl border border-gray-100 shadow-sm overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-gray-100 bg-gray-50/30 flex items-center gap-2">
|
||||||
|
<FileText className="w-5 h-5 text-blue-600" />
|
||||||
|
<h3 className="font-black text-gray-900 uppercase tracking-tight">Bảng kê chi tiết các khoản chi</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50/50 text-[10px] font-black text-gray-400 uppercase tracking-widest border-b border-gray-100">
|
||||||
|
<th className="px-4 py-4 text-center">STT</th>
|
||||||
|
<th className="px-4 py-4 min-w-[100px]">Ngày giờ</th>
|
||||||
|
<th className="px-4 py-4 min-w-[80px]">Chặng</th>
|
||||||
|
<th className="px-4 py-4">Dịch vụ sử dụng</th>
|
||||||
|
<th className="px-4 py-4 text-right">Số tiền</th>
|
||||||
|
<th className="px-4 py-4 min-w-[120px]">Người đã trả</th>
|
||||||
|
<th className="px-4 py-4">Ghi chú</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-50">
|
||||||
|
{stats.list.length > 0 ? (
|
||||||
|
stats.list.map((exp: any, index: number) => (
|
||||||
|
<tr key={exp.id} className="hover:bg-blue-50/20 transition-colors text-sm">
|
||||||
|
<td className="px-4 py-4 text-center font-bold text-gray-400">{index + 1}</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<div className="flex items-center gap-1.5 text-gray-600 font-medium">
|
||||||
|
<Calendar className="w-3.5 h-3.5 opacity-40" />
|
||||||
|
{exp.date ? format(new Date(exp.date), 'dd/MM HH:mm') : '--/--'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 whitespace-nowrap">
|
||||||
|
<span className="px-2 py-1 bg-blue-50 text-blue-600 rounded-lg font-bold text-[10px]">
|
||||||
|
Chặng {exp.legSequence}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 font-semibold text-gray-800">
|
||||||
|
{exp.description || 'Không có mô tả'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-right font-black text-blue-600">
|
||||||
|
{Number(exp.amount).toLocaleString()}đ
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<div className="flex items-center gap-1.5 text-gray-600">
|
||||||
|
<User className="w-3.5 h-3.5 opacity-40" />
|
||||||
|
{exp.paidBy?.name || 'Chưa rõ'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-xs text-gray-400 italic">
|
||||||
|
{exp.note || '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
// Khung bảng mẫu khi chưa có dữ liệu (Placeholder)
|
||||||
|
[1, 2, 3].map((i) => (
|
||||||
|
<tr key={`sample-${i}`} className="opacity-30 grayscale pointer-events-none select-none text-sm">
|
||||||
|
<td className="px-4 py-4 text-center font-bold text-gray-300">{i}</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<div className="flex items-center gap-1.5 text-gray-300 font-medium">
|
||||||
|
<Calendar className="w-3.5 h-3.5 opacity-20" /> --/-- --:--
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 whitespace-nowrap">
|
||||||
|
<span className="px-2 py-1 bg-gray-100 text-gray-400 rounded-lg font-bold text-[10px]">Chặng -</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-gray-300 italic">Ví dụ: Vé tham quan, Tiền ăn trưa...</td>
|
||||||
|
<td className="px-4 py-4 text-right font-black text-gray-300">0đ</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<div className="flex items-center gap-1.5 text-gray-300">
|
||||||
|
<User className="w-3.5 h-3.5 opacity-20" /> Chưa có dữ liệu
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-xs text-gray-200 italic">-</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-gray-400 text-xs px-2">
|
<div className="flex items-center gap-2 text-gray-400 text-xs px-2">
|
||||||
<Info className="w-4 h-4" />
|
<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>
|
<p>Chi phí được tự động tính toán dựa trên hóa đơn của các chặng.</p>
|
||||||
|
|||||||
@@ -539,12 +539,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>
|
||||||
|
|||||||
Reference in New Issue
Block a user