Cài đặt tính năng hiển thị chi phí

This commit is contained in:
2026-06-15 20:20:34 +07:00
parent 879f20985c
commit f52580717f
8 changed files with 319 additions and 42 deletions
+4 -1
View File
@@ -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: {
+1 -1
View File
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;
+4
View File
@@ -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
View File
@@ -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: {
+38 -2
View File
@@ -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ấ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">
+259 -35
View File
@@ -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 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 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 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 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"> dụ: 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 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>
+5 -2
View File
@@ -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>