Bổ sung tính năng cập nhật tiền của địa điểm
This commit is contained in:
@@ -67,6 +67,9 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
longitude: 106.7009,
|
||||
type: 'VISIT',
|
||||
legId: '',
|
||||
note: '',
|
||||
expenseAmount: '',
|
||||
expenseCategory: 'OTHER',
|
||||
plannedStart: '',
|
||||
plannedEnd: ''
|
||||
});
|
||||
@@ -79,6 +82,9 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (editingLocation) {
|
||||
const leg = legs.find(l => l.id === editingLocation.legId);
|
||||
const expense = leg?.expenses?.find((e: any) => e.locationId === editingLocation.id);
|
||||
|
||||
setFormData({
|
||||
name: editingLocation.name || '',
|
||||
address: editingLocation.address || '',
|
||||
@@ -86,6 +92,9 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
longitude: editingLocation.longitude,
|
||||
type: editingLocation.type || 'VISIT',
|
||||
legId: editingLocation.legId || '',
|
||||
note: editingLocation.note || '',
|
||||
expenseAmount: expense?.amount?.toString() || '',
|
||||
expenseCategory: expense?.category || 'OTHER',
|
||||
plannedStart: editingLocation.plannedStart ? editingLocation.plannedStart.slice(0, 16) : '',
|
||||
plannedEnd: editingLocation.plannedEnd ? editingLocation.plannedEnd.slice(0, 16) : ''
|
||||
});
|
||||
@@ -94,6 +103,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
...prev,
|
||||
name: '', address: '',
|
||||
legId: initialLegId || (legs.length > 0 ? legs[0].id : ''),
|
||||
note: '', expenseAmount: '', expenseCategory: 'OTHER',
|
||||
plannedStart: '', plannedEnd: ''
|
||||
}));
|
||||
}
|
||||
@@ -195,6 +205,31 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
<input className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||
value={formData.address} onChange={e => setFormData({...formData, address: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Ghi chú / Dịch vụ sử dụng</label>
|
||||
<textarea className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none resize-none" rows={2}
|
||||
placeholder="Ví dụ: Ăn trưa tại quán X, thuê hướng dẫn viên..."
|
||||
value={formData.note} onChange={e => setFormData({...formData, note: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Số tiền (VNĐ)</label>
|
||||
<input type="number" className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||
placeholder="0"
|
||||
value={formData.expenseAmount} onChange={e => setFormData({...formData, expenseAmount: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Loại dịch vụ</label>
|
||||
<select className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||
value={formData.expenseCategory} onChange={e => setFormData({...formData, expenseCategory: e.target.value})}>
|
||||
<option value="FOOD">Ăn uống</option>
|
||||
<option value="TRANSPORT">Di chuyển</option>
|
||||
<option value="ACCOMMODATION">Chỗ ở</option>
|
||||
<option value="TICKET">Vé tham quan</option>
|
||||
<option value="OTHER">Khác</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Gán vào chặng</label>
|
||||
<select required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||
|
||||
@@ -195,6 +195,8 @@ export const ItineraryTimeline = ({
|
||||
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
||||
: null;
|
||||
|
||||
const locationExpense = leg.expenses?.find((e: any) => e.locationId === location.id);
|
||||
|
||||
const isStartPoint = legs[0]?.id === leg.id && idx === 0;
|
||||
const isEndPoint = legs[legs.length - 1]?.id === leg.id && idx === leg.locations.length - 1;
|
||||
|
||||
@@ -234,12 +236,23 @@ export const ItineraryTimeline = ({
|
||||
<MapPin className="w-3 h-3 mr-1" />
|
||||
<span className="truncate max-w-[200px] sm:max-w-md">{location.address}</span>
|
||||
</div>
|
||||
{location.note && (
|
||||
<div className="mt-2 text-xs text-gray-600 bg-gray-50 p-2 rounded-lg border border-gray-100 italic">
|
||||
{location.note}
|
||||
</div>
|
||||
)}
|
||||
{dwellMinutes !== null && (
|
||||
<div className="flex items-center text-xs text-amber-600 font-medium mt-1">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
<span>Thời gian dừng: {formatTravelTime(dwellMinutes)}</span>
|
||||
</div>
|
||||
)}
|
||||
{locationExpense && (
|
||||
<div className="flex items-center text-xs text-indigo-600 font-bold mt-1">
|
||||
<Zap className="w-3 h-3 mr-1" />
|
||||
<span>Chi phí: {Number(locationExpense.amount).toLocaleString()}đ ({locationExpense.category})</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-right flex flex-col items-end">
|
||||
|
||||
Vendored
+10
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-1
@@ -86,9 +86,10 @@ export const ItineraryTimeline = ({ onAddLocation, onEditLocation }) => {
|
||||
const dwellMinutes = (location.plannedStart && location.plannedEnd)
|
||||
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
||||
: null;
|
||||
const locationExpense = leg.expenses?.find((e) => e.locationId === location.id);
|
||||
const isStartPoint = legs[0]?.id === leg.id && idx === 0;
|
||||
const isEndPoint = legs[legs.length - 1]?.id === leg.id && idx === leg.locations.length - 1;
|
||||
return (_jsxs("div", { children: [_jsxs("div", { className: "relative flex group mb-6", children: [_jsx("div", { className: "z-10 mt-1.5 mr-4", children: _jsx("button", { onClick: () => toggleComplete(location.id), className: `transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`, children: location.status === 'COMPLETED' ? (_jsx(CheckCircle2, { className: "w-8 h-8 bg-white rounded-full" })) : (_jsx(Circle, { className: "w-8 h-8 bg-white rounded-full fill-white" })) }) }), _jsxs("div", { className: `flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${location.status === 'COMPLETED' ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'}`, children: [_jsxs("div", { className: "flex justify-between items-start", children: [_jsxs("div", { children: [isStartPoint && (_jsx("span", { className: "inline-block px-2 py-0.5 bg-green-100 text-green-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider", children: "\u0110i\u1EC3m b\u1EAFt \u0111\u1EA7u" })), isEndPoint && (_jsx("span", { className: "inline-block px-2 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider", children: "\u0110i\u1EC3m k\u1EBFt th\u00FAc" })), _jsx("h3", { className: `font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`, children: location.name }), _jsxs("div", { className: "flex items-center text-sm text-gray-500 mt-1", children: [_jsx(MapPin, { className: "w-3 h-3 mr-1" }), _jsx("span", { className: "truncate max-w-[200px] sm:max-w-md", children: location.address })] }), dwellMinutes !== null && (_jsxs("div", { className: "flex items-center text-xs text-amber-600 font-medium mt-1", children: [_jsx(Clock, { className: "w-3 h-3 mr-1" }), _jsxs("span", { children: ["Th\u1EDDi gian d\u1EEBng: ", formatTravelTime(dwellMinutes)] })] }))] }), _jsxs("div", { className: "text-right flex flex-col items-end", children: [_jsxs("div", { className: "flex items-center text-sm font-medium text-blue-600", children: [_jsx(Clock, { className: "w-3 h-3 mr-1" }), location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'] }), location.status === 'COMPLETED' && location.actualStart && (_jsxs("div", { className: "text-[10px] text-gray-400 mt-1 italic", children: ["Th\u1EF1c t\u1EBF: ", format(parseISO(location.actualStart), 'HH:mm')] })), ['OWNER', 'MANAGER'].includes(userRole || '') && !isStartPoint && !isEndPoint && (_jsxs("div", { className: "flex gap-1 mt-2", children: [_jsx("button", { onClick: () => onEditLocation?.(location), className: "p-1 text-gray-400 hover:text-blue-600 transition-colors", children: _jsx(Edit2, { className: "w-3.5 h-3.5" }) }), _jsx("button", { onClick: () => handleDeleteLocation(location.id), className: "p-1 text-gray-400 hover:text-red-600 transition-colors", children: _jsx(Trash2, { className: "w-3.5 h-3.5" }) })] }))] })] }), _jsx(TimeVariance, { planned: location.plannedStart || '', actual: location.actualStart || null })] })] }), distanceToNext !== null && travelTimeMinutes !== null && (_jsxs("div", { className: "ml-4 -mt-4 mb-2 flex items-center gap-3", children: [_jsx("div", { className: "w-8 flex justify-center", children: _jsx(Navigation, { className: "w-3 h-3 text-blue-400 rotate-180" }) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("span", { className: "text-[10px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100", children: [distanceToNext.toFixed(2), " km"] }), _jsxs("span", { className: "text-[10px] font-bold text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full border border-gray-100 flex items-center gap-1", children: [_jsx(Clock, { className: "w-2.5 h-2.5" }), "~ ", formatTravelTime(travelTimeMinutes)] })] })] }))] }, location.id));
|
||||
return (_jsxs("div", { children: [_jsxs("div", { className: "relative flex group mb-6", children: [_jsx("div", { className: "z-10 mt-1.5 mr-4", children: _jsx("button", { onClick: () => toggleComplete(location.id), className: `transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`, children: location.status === 'COMPLETED' ? (_jsx(CheckCircle2, { className: "w-8 h-8 bg-white rounded-full" })) : (_jsx(Circle, { className: "w-8 h-8 bg-white rounded-full fill-white" })) }) }), _jsxs("div", { className: `flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${location.status === 'COMPLETED' ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'}`, children: [_jsxs("div", { className: "flex justify-between items-start", children: [_jsxs("div", { children: [isStartPoint && (_jsx("span", { className: "inline-block px-2 py-0.5 bg-green-100 text-green-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider", children: "\u0110i\u1EC3m b\u1EAFt \u0111\u1EA7u" })), isEndPoint && (_jsx("span", { className: "inline-block px-2 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider", children: "\u0110i\u1EC3m k\u1EBFt th\u00FAc" })), _jsx("h3", { className: `font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`, children: location.name }), _jsxs("div", { className: "flex items-center text-sm text-gray-500 mt-1", children: [_jsx(MapPin, { className: "w-3 h-3 mr-1" }), _jsx("span", { className: "truncate max-w-[200px] sm:max-w-md", children: location.address })] }), location.note && (_jsx("div", { className: "mt-2 text-xs text-gray-600 bg-gray-50 p-2 rounded-lg border border-gray-100 italic", children: location.note })), dwellMinutes !== null && (_jsxs("div", { className: "flex items-center text-xs text-amber-600 font-medium mt-1", children: [_jsx(Clock, { className: "w-3 h-3 mr-1" }), _jsxs("span", { children: ["Th\u1EDDi gian d\u1EEBng: ", formatTravelTime(dwellMinutes)] })] })), locationExpense && (_jsxs("div", { className: "flex items-center text-xs text-indigo-600 font-bold mt-1", children: [_jsx(Zap, { className: "w-3 h-3 mr-1" }), _jsxs("span", { children: ["Chi ph\u00ED: ", Number(locationExpense.amount).toLocaleString(), "\u0111 (", locationExpense.category, ")"] })] }))] }), _jsxs("div", { className: "text-right flex flex-col items-end", children: [_jsxs("div", { className: "flex items-center text-sm font-medium text-blue-600", children: [_jsx(Clock, { className: "w-3 h-3 mr-1" }), location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'] }), location.status === 'COMPLETED' && location.actualStart && (_jsxs("div", { className: "text-[10px] text-gray-400 mt-1 italic", children: ["Th\u1EF1c t\u1EBF: ", format(parseISO(location.actualStart), 'HH:mm')] })), ['OWNER', 'MANAGER'].includes(userRole || '') && !isStartPoint && !isEndPoint && (_jsxs("div", { className: "flex gap-1 mt-2", children: [_jsx("button", { onClick: () => onEditLocation?.(location), className: "p-1 text-gray-400 hover:text-blue-600 transition-colors", children: _jsx(Edit2, { className: "w-3.5 h-3.5" }) }), _jsx("button", { onClick: () => handleDeleteLocation(location.id), className: "p-1 text-gray-400 hover:text-red-600 transition-colors", children: _jsx(Trash2, { className: "w-3.5 h-3.5" }) })] }))] })] }), _jsx(TimeVariance, { planned: location.plannedStart || '', actual: location.actualStart || null })] })] }), distanceToNext !== null && travelTimeMinutes !== null && (_jsxs("div", { className: "ml-4 -mt-4 mb-2 flex items-center gap-3", children: [_jsx("div", { className: "w-8 flex justify-center", children: _jsx(Navigation, { className: "w-3 h-3 text-blue-400 rotate-180" }) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("span", { className: "text-[10px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100", children: [distanceToNext.toFixed(2), " km"] }), _jsxs("span", { className: "text-[10px] font-bold text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full border border-gray-100 flex items-center gap-1", children: [_jsx(Clock, { className: "w-2.5 h-2.5" }), "~ ", formatTravelTime(travelTimeMinutes)] })] })] }))] }, location.id));
|
||||
}) })] }, leg.id));
|
||||
})), ['OWNER', 'MANAGER'].includes(userRole || '') && (_jsxs("div", { className: "flex flex-col gap-3 pb-20 mt-8", children: [_jsxs("button", { onClick: handleDeclareLegs, className: "w-full py-4 rounded-2xl bg-white text-blue-600 border-2 border-dashed border-blue-200 hover:bg-blue-50 transition-all flex items-center justify-center gap-2 text-sm font-bold", children: [_jsx(List, { className: "w-5 h-5" }), legs.length > 0 ? "Khai báo lại số lượng chặng" : "Bắt đầu bằng việc khai báo số chặng"] }), _jsxs("button", { onClick: handleAddLeg, className: "w-full py-4 rounded-2xl bg-blue-600 text-white shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all flex items-center justify-center gap-2 text-sm font-bold", children: [_jsx(Plus, { className: "w-5 h-5" }), " Th\u00EAm ch\u1EB7ng l\u1EBB v\u00E0o cu\u1ED1i"] })] }))] }) }));
|
||||
};
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+47
-9
@@ -148,6 +148,19 @@ let TourController = class TourController {
|
||||
plannedStart: body.plannedStart ? new Date(body.plannedStart) : null,
|
||||
plannedEnd: body.plannedEnd ? new Date(body.plannedEnd) : null,
|
||||
}
|
||||
}).then(async (loc) => {
|
||||
if (body.expenseAmount && Number(body.expenseAmount) > 0) {
|
||||
await this.prisma.expense.create({
|
||||
data: {
|
||||
amount: Number(body.expenseAmount),
|
||||
category: body.expenseCategory || 'OTHER',
|
||||
locationId: loc.id,
|
||||
legId: loc.legId,
|
||||
description: `Chi phí tại ${loc.name}`
|
||||
}
|
||||
});
|
||||
}
|
||||
return loc;
|
||||
});
|
||||
}
|
||||
async updateTourStartPoint(tourId, body, req) {
|
||||
@@ -396,19 +409,44 @@ let LocationController = class LocationController {
|
||||
this.prisma = prisma;
|
||||
}
|
||||
async updateLocation(id, body) {
|
||||
return this.prisma.location.update({
|
||||
const { expenseAmount, expenseCategory, ...data } = body;
|
||||
const location = await this.prisma.location.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: body.name,
|
||||
address: body.address,
|
||||
latitude: body.latitude,
|
||||
longitude: body.longitude,
|
||||
type: body.type,
|
||||
plannedStart: body.plannedStart ? new Date(body.plannedStart) : undefined,
|
||||
plannedEnd: body.plannedEnd ? new Date(body.plannedEnd) : undefined,
|
||||
status: body.status,
|
||||
name: data.name,
|
||||
address: data.address,
|
||||
latitude: data.latitude,
|
||||
longitude: data.longitude,
|
||||
type: data.type,
|
||||
plannedStart: data.plannedStart ? new Date(data.plannedStart) : undefined,
|
||||
plannedEnd: data.plannedEnd ? new Date(data.plannedEnd) : undefined,
|
||||
status: data.status,
|
||||
}
|
||||
});
|
||||
if (expenseAmount !== undefined) {
|
||||
const amount = Number(expenseAmount);
|
||||
const existingExpense = await this.prisma.expense.findFirst({
|
||||
where: { locationId: id }
|
||||
});
|
||||
if (existingExpense) {
|
||||
await this.prisma.expense.update({
|
||||
where: { id: existingExpense.id },
|
||||
data: { amount, category: expenseCategory || 'OTHER' }
|
||||
});
|
||||
}
|
||||
else if (amount > 0) {
|
||||
await this.prisma.expense.create({
|
||||
data: {
|
||||
amount,
|
||||
category: expenseCategory || 'OTHER',
|
||||
locationId: id,
|
||||
legId: location.legId,
|
||||
description: `Chi phí tại ${location.name}`
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return location;
|
||||
}
|
||||
async deleteLocation(id) {
|
||||
await this.prisma.location.delete({ where: { id } });
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -132,6 +132,20 @@ class TourController {
|
||||
plannedStart: body.plannedStart ? new Date(body.plannedStart) : null,
|
||||
plannedEnd: body.plannedEnd ? new Date(body.plannedEnd) : null,
|
||||
}
|
||||
}).then(async (loc) => {
|
||||
// Thêm nhanh chi phí nếu có số tiền
|
||||
if (body.expenseAmount && Number(body.expenseAmount) > 0) {
|
||||
await this.prisma.expense.create({
|
||||
data: {
|
||||
amount: Number(body.expenseAmount),
|
||||
category: body.expenseCategory || 'OTHER',
|
||||
locationId: loc.id,
|
||||
legId: loc.legId,
|
||||
description: `Chi phí tại ${loc.name}`
|
||||
}
|
||||
});
|
||||
}
|
||||
return loc;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -340,19 +354,47 @@ class LocationController {
|
||||
|
||||
@Patch(':id')
|
||||
async updateLocation(@Param('id', ParseUUIDPipe) id: string, @Body() body: any) {
|
||||
return this.prisma.location.update({
|
||||
const { expenseAmount, expenseCategory, ...data } = body;
|
||||
|
||||
const location = await this.prisma.location.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: body.name,
|
||||
address: body.address,
|
||||
latitude: body.latitude,
|
||||
longitude: body.longitude,
|
||||
type: body.type,
|
||||
plannedStart: body.plannedStart ? new Date(body.plannedStart) : undefined,
|
||||
plannedEnd: body.plannedEnd ? new Date(body.plannedEnd) : undefined,
|
||||
status: body.status,
|
||||
name: data.name,
|
||||
address: data.address,
|
||||
latitude: data.latitude,
|
||||
longitude: data.longitude,
|
||||
type: data.type,
|
||||
plannedStart: data.plannedStart ? new Date(data.plannedStart) : undefined,
|
||||
plannedEnd: data.plannedEnd ? new Date(data.plannedEnd) : undefined,
|
||||
status: data.status,
|
||||
}
|
||||
});
|
||||
|
||||
if (expenseAmount !== undefined) {
|
||||
const amount = Number(expenseAmount);
|
||||
const existingExpense = await this.prisma.expense.findFirst({
|
||||
where: { locationId: id }
|
||||
});
|
||||
|
||||
if (existingExpense) {
|
||||
await this.prisma.expense.update({
|
||||
where: { id: existingExpense.id },
|
||||
data: { amount, category: expenseCategory || 'OTHER' }
|
||||
});
|
||||
} else if (amount > 0) {
|
||||
await this.prisma.expense.create({
|
||||
data: {
|
||||
amount,
|
||||
category: expenseCategory || 'OTHER',
|
||||
locationId: id,
|
||||
legId: location.legId,
|
||||
description: `Chi phí tại ${location.name}`
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return location;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
|
||||
Reference in New Issue
Block a user