Dashboard adoptation
This commit is contained in:
@@ -21,6 +21,7 @@ import org.springframework.format.annotation.DateTimeFormat;
|
|||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/wallet")
|
@RequestMapping("/api/wallet")
|
||||||
@@ -34,6 +36,20 @@ public class WalletController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/users")
|
||||||
|
public ResponseEntity<List<Map<String, Object>>> getUsers() {
|
||||||
|
List<User> users = userRepository.findAll();
|
||||||
|
List<Map<String, Object>> userList = users.stream().map(user -> {
|
||||||
|
Map<String, Object> map = new HashMap<>();
|
||||||
|
map.put("id", user.getId());
|
||||||
|
map.put("name", user.getName());
|
||||||
|
map.put("mobileNumber", user.getMobileNumber());
|
||||||
|
map.put("ritzTokenBalance", user.getRitzTokenBalance());
|
||||||
|
return map;
|
||||||
|
}).collect(Collectors.toList());
|
||||||
|
return ResponseEntity.ok(userList);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/transactions/{userId}")
|
@GetMapping("/transactions/{userId}")
|
||||||
public ResponseEntity<List<TokenTransaction>> getTransactions(@PathVariable Long userId) {
|
public ResponseEntity<List<TokenTransaction>> getTransactions(@PathVariable Long userId) {
|
||||||
return ResponseEntity.ok(tokenService.getTransactions(userId));
|
return ResponseEntity.ok(tokenService.getTransactions(userId));
|
||||||
|
|||||||
@@ -2,11 +2,22 @@ package com.rit.canteen.sales.repository;
|
|||||||
|
|
||||||
import com.rit.canteen.sales.model.TokenTransaction;
|
import com.rit.canteen.sales.model.TokenTransaction;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface TokenTransactionRepository extends JpaRepository<TokenTransaction, Long> {
|
public interface TokenTransactionRepository extends JpaRepository<TokenTransaction, Long> {
|
||||||
List<TokenTransaction> findByUserIdOrderByTimestampDesc(Long userId);
|
List<TokenTransaction> findByUserIdOrderByTimestampDesc(Long userId);
|
||||||
List<TokenTransaction> findAllByOrderByTimestampDesc();
|
List<TokenTransaction> findAllByOrderByTimestampDesc();
|
||||||
|
|
||||||
|
@Query("SELECT SUM(t.amount) FROM TokenTransaction t WHERE t.type = :type")
|
||||||
|
BigDecimal sumByType(@Param("type") TokenTransaction.TransactionType type);
|
||||||
|
|
||||||
|
@Query("SELECT SUM(t.amount) FROM TokenTransaction t WHERE t.type = :type AND t.timestamp >= :start AND t.timestamp <= :end")
|
||||||
|
BigDecimal sumByTypeInRange(@Param("type") TokenTransaction.TransactionType type, @Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
|||||||
"OR u.mobileNumber LIKE CONCAT('%', :search, '%')")
|
"OR u.mobileNumber LIKE CONCAT('%', :search, '%')")
|
||||||
org.springframework.data.domain.Page<User> findByNameOrMobileContainingIgnoreCase(String search, org.springframework.data.domain.Pageable pageable);
|
org.springframework.data.domain.Page<User> findByNameOrMobileContainingIgnoreCase(String search, org.springframework.data.domain.Pageable pageable);
|
||||||
|
|
||||||
@jakarta.persistence.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE)
|
@org.springframework.data.jpa.repository.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE)
|
||||||
@Query("SELECT u FROM User u WHERE u.id = :id")
|
@Query("SELECT u FROM User u WHERE u.id = :id")
|
||||||
Optional<User> findByIdWithLock(@org.springframework.data.repository.query.Param("id") Long id);
|
Optional<User> findByIdWithLock(@org.springframework.data.repository.query.Param("id") Long id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.rit.canteen.sales.model.TrendingItem;
|
|||||||
import com.rit.canteen.sales.repository.OrderRepository;
|
import com.rit.canteen.sales.repository.OrderRepository;
|
||||||
import com.rit.canteen.sales.repository.PurchaseOrderRepository;
|
import com.rit.canteen.sales.repository.PurchaseOrderRepository;
|
||||||
import com.rit.canteen.sales.repository.VendorRepository;
|
import com.rit.canteen.sales.repository.VendorRepository;
|
||||||
|
import com.rit.canteen.sales.repository.TokenTransactionRepository;
|
||||||
import com.rit.canteen.sales.model.ProcurementDashboardData;
|
import com.rit.canteen.sales.model.ProcurementDashboardData;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -29,11 +30,15 @@ public class DashboardService {
|
|||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private VendorRepository vendorRepository;
|
private VendorRepository vendorRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TokenTransactionRepository tokenTransactionRepository;
|
||||||
|
|
||||||
public GeneralDashboardData getGeneralDashboardData(LocalDateTime from, LocalDateTime to) {
|
public GeneralDashboardData getGeneralDashboardData(LocalDateTime from, LocalDateTime to) {
|
||||||
if (from == null) from = LocalDate.now().atStartOfDay();
|
if (from == null) from = LocalDate.now().atStartOfDay();
|
||||||
if (to == null) to = LocalDate.now().atTime(LocalTime.MAX);
|
if (to == null) to = LocalDate.now().atTime(LocalTime.MAX);
|
||||||
|
|
||||||
|
System.out.println("[DIAGNOSTIC] Final timestamp range for service logic: " + from + " to " + to);
|
||||||
DashboardStats stats = getDashboardStats(from, to);
|
DashboardStats stats = getDashboardStats(from, to);
|
||||||
|
|
||||||
System.out.println("Fetching dashboard data for range: " + from + " to " + to);
|
System.out.println("Fetching dashboard data for range: " + from + " to " + to);
|
||||||
@@ -94,7 +99,9 @@ public class DashboardService {
|
|||||||
orderInsight.put("color", "bg-rose-50 text-rose-600 border-rose-100");
|
orderInsight.put("color", "bg-rose-50 text-rose-600 border-rose-100");
|
||||||
insights.add(orderInsight);
|
insights.add(orderInsight);
|
||||||
|
|
||||||
BigDecimal avg = stats.getTotalSales() > 0 ? BigDecimal.valueOf(stats.getTotalSales()).divide(BigDecimal.valueOf(stats.getActiveOrders()), 2, RoundingMode.HALF_UP) : BigDecimal.ZERO;
|
BigDecimal avg = (stats.getTotalSales() > 0 && stats.getActiveOrders() > 0)
|
||||||
|
? BigDecimal.valueOf(stats.getTotalSales()).divide(BigDecimal.valueOf(stats.getActiveOrders()), 2, RoundingMode.HALF_UP)
|
||||||
|
: BigDecimal.ZERO;
|
||||||
Map<String, String> avgInsight = new HashMap<>();
|
Map<String, String> avgInsight = new HashMap<>();
|
||||||
avgInsight.put("text", "₹" + avg + " average order value! Either everyone's hungry or just living large 🔥😋");
|
avgInsight.put("text", "₹" + avg + " average order value! Either everyone's hungry or just living large 🔥😋");
|
||||||
avgInsight.put("color", "bg-emerald-50 text-emerald-600 border-emerald-100");
|
avgInsight.put("color", "bg-emerald-50 text-emerald-600 border-emerald-100");
|
||||||
@@ -127,17 +134,22 @@ public class DashboardService {
|
|||||||
LocalDateTime startOfYesterday = LocalDate.now().minusDays(1).atStartOfDay();
|
LocalDateTime startOfYesterday = LocalDate.now().minusDays(1).atStartOfDay();
|
||||||
LocalDateTime endOfYesterday = LocalDate.now().minusDays(1).atTime(LocalTime.MAX);
|
LocalDateTime endOfYesterday = LocalDate.now().minusDays(1).atTime(LocalTime.MAX);
|
||||||
|
|
||||||
BigDecimal totalRevenueRaw = orderRepository.getTotalRevenue();
|
System.out.println("[DIAGNOSTIC] Fetching Total Revenue...");
|
||||||
|
BigDecimal totalRevenueRaw = tokenTransactionRepository.sumByType(com.rit.canteen.sales.model.TokenTransaction.TransactionType.TOPUP);
|
||||||
|
System.out.println("[DIAGNOSTIC] Raw Total Revenue: " + totalRevenueRaw);
|
||||||
long totalSales = totalRevenueRaw != null ? totalRevenueRaw.longValue() : 0;
|
long totalSales = totalRevenueRaw != null ? totalRevenueRaw.longValue() : 0;
|
||||||
|
|
||||||
BigDecimal periodRevenueRaw = orderRepository.getRevenuePerPeriod(from, to);
|
System.out.println("[DIAGNOSTIC] Fetching Period Revenue for range: " + from + " to " + to);
|
||||||
|
BigDecimal periodRevenueRaw = tokenTransactionRepository.sumByTypeInRange(com.rit.canteen.sales.model.TokenTransaction.TransactionType.TOPUP, from, to);
|
||||||
|
System.out.println("[DIAGNOSTIC] Raw Period Revenue: " + periodRevenueRaw);
|
||||||
long periodRevenue = periodRevenueRaw != null ? periodRevenueRaw.longValue() : 0;
|
long periodRevenue = periodRevenueRaw != null ? periodRevenueRaw.longValue() : 0;
|
||||||
|
|
||||||
int activeOrders = (int) orderRepository.countByCreatedAtBetween(from, to);
|
int activeOrders = (int) orderRepository.countByCreatedAtBetween(from, to);
|
||||||
int dailyCustomers = (int) orderRepository.countUniqueUsersInRange(from, to);
|
int dailyCustomers = (int) orderRepository.countUniqueUsersInRange(from, to);
|
||||||
|
|
||||||
BigDecimal todayRevenue = orderRepository.getRevenuePerPeriod(startOfToday, endOfToday);
|
BigDecimal todayRevenue = tokenTransactionRepository.sumByTypeInRange(com.rit.canteen.sales.model.TokenTransaction.TransactionType.TOPUP, startOfToday, endOfToday);
|
||||||
BigDecimal yesterdayRevenue = orderRepository.getRevenuePerPeriod(startOfYesterday, endOfYesterday);
|
BigDecimal yesterdayRevenue = tokenTransactionRepository.sumByTypeInRange(com.rit.canteen.sales.model.TokenTransaction.TransactionType.TOPUP, startOfYesterday, endOfYesterday);
|
||||||
|
System.out.println("[DIAGNOSTIC] Today vs Yesterday: " + todayRevenue + " / " + yesterdayRevenue);
|
||||||
|
|
||||||
BigDecimal totalExpensesRaw = purchaseOrderRepository.getTotalPurchaseAmount();
|
BigDecimal totalExpensesRaw = purchaseOrderRepository.getTotalPurchaseAmount();
|
||||||
long totalExpenses = totalExpensesRaw != null ? totalExpensesRaw.longValue() : 0;
|
long totalExpenses = totalExpensesRaw != null ? totalExpensesRaw.longValue() : 0;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import Reports from './pages/Reports.tsx';
|
|||||||
import Feedback from './pages/Feedback.tsx';
|
import Feedback from './pages/Feedback.tsx';
|
||||||
import Ritz from './pages/Ritz.tsx';
|
import Ritz from './pages/Ritz.tsx';
|
||||||
import RitzCirculation from './pages/RitzCirculation.tsx';
|
import RitzCirculation from './pages/RitzCirculation.tsx';
|
||||||
|
import ManageWallets from './pages/ManageWallets.tsx';
|
||||||
|
|
||||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
const isLoggedIn = sessionStorage.getItem('isLoggedIn') === 'true';
|
const isLoggedIn = sessionStorage.getItem('isLoggedIn') === 'true';
|
||||||
@@ -94,6 +95,7 @@ function App() {
|
|||||||
<Route path="feedback" element={<Feedback />} />
|
<Route path="feedback" element={<Feedback />} />
|
||||||
<Route path="ritz/overview" element={<Ritz />} />
|
<Route path="ritz/overview" element={<Ritz />} />
|
||||||
<Route path="ritz/circulation" element={<RitzCirculation />} />
|
<Route path="ritz/circulation" element={<RitzCirculation />} />
|
||||||
|
<Route path="ritz/wallets" element={<ManageWallets />} />
|
||||||
|
|
||||||
{/* Stores */}
|
{/* Stores */}
|
||||||
<Route path="stores/terminals" element={<Terminals />} />
|
<Route path="stores/terminals" element={<Terminals />} />
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ const menuItems: MenuItem[] = [
|
|||||||
icon: CircleDollarSign,
|
icon: CircleDollarSign,
|
||||||
subMenu: [
|
subMenu: [
|
||||||
{ title: 'Overview', path: '/ritz/overview' },
|
{ title: 'Overview', path: '/ritz/overview' },
|
||||||
{ title: 'Ritz in Circulation', path: '/ritz/circulation' }
|
{ title: 'Ritz in Circulation', path: '/ritz/circulation' },
|
||||||
|
{ title: 'Manage Wallets', path: '/ritz/wallets' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -33,6 +33,22 @@ const Dashboard = () => {
|
|||||||
const [data, setData] = useState<any>(null);
|
const [data, setData] = useState<any>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const toLocalISOString = (date: Date) => {
|
||||||
|
const tzo = -date.getTimezoneOffset(),
|
||||||
|
dif = tzo >= 0 ? '+' : '-',
|
||||||
|
pad = (num: number) => {
|
||||||
|
const norm = Math.floor(Math.abs(num));
|
||||||
|
return (norm < 10 ? '0' : '') + norm;
|
||||||
|
};
|
||||||
|
return date.getFullYear() +
|
||||||
|
'-' + pad(date.getMonth() + 1) +
|
||||||
|
'-' + pad(date.getDate()) +
|
||||||
|
'T' + pad(date.getHours()) +
|
||||||
|
':' + pad(date.getMinutes()) +
|
||||||
|
':' + pad(date.getSeconds()) +
|
||||||
|
'.' + pad(date.getMilliseconds());
|
||||||
|
};
|
||||||
|
|
||||||
const getRangeDates = (range: string) => {
|
const getRangeDates = (range: string) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const start = new Date();
|
const start = new Date();
|
||||||
@@ -65,13 +81,13 @@ const Dashboard = () => {
|
|||||||
from.setHours(0, 0, 0, 0);
|
from.setHours(0, 0, 0, 0);
|
||||||
const to = new Date(customDates.to);
|
const to = new Date(customDates.to);
|
||||||
to.setHours(23, 59, 59, 999);
|
to.setHours(23, 59, 59, 999);
|
||||||
return { from: from.toISOString(), to: to.toISOString() };
|
return { from: toLocalISOString(from), to: toLocalISOString(to) };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
default:
|
default:
|
||||||
start.setHours(0, 0, 0, 0);
|
start.setHours(0, 0, 0, 0);
|
||||||
}
|
}
|
||||||
return { from: start.toISOString(), to: end.toISOString() };
|
return { from: toLocalISOString(start), to: toLocalISOString(end) };
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -88,6 +104,7 @@ const Dashboard = () => {
|
|||||||
url += `?${params.toString()}`;
|
url += `?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[DASHBOARD-TRACE] Fetching stats from:', url);
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|||||||
294
frontend/src/pages/ManageWallets.tsx
Normal file
294
frontend/src/pages/ManageWallets.tsx
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Search,
|
||||||
|
Wallet,
|
||||||
|
Plus,
|
||||||
|
X,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
ArrowRight,
|
||||||
|
User as UserIcon,
|
||||||
|
Phone
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
mobileNumber: string;
|
||||||
|
ritzTokenBalance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ManageWallets = () => {
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [status, setStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await fetch('/api/wallet/users');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setUsers(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching users:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTopUp = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!selectedUser || !amount || parseFloat(amount) <= 0) return;
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
setStatus(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/wallet/topup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
userId: selectedUser.id,
|
||||||
|
amount: parseFloat(amount)
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
setStatus({ type: 'success', message: `Successfully added ${amount} Ritz to ${selectedUser.name}'s wallet` });
|
||||||
|
fetchUsers(); // Refresh list
|
||||||
|
setAmount('');
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setStatus(null);
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
setStatus({ type: 'error', message: data.error || 'Failed to credit tokens' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setStatus({ type: 'error', message: 'Connection error. Please try again.' });
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredUsers = users.filter(user =>
|
||||||
|
(user.name?.toLowerCase() || '').includes(searchQuery.toLowerCase()) ||
|
||||||
|
user.mobileNumber.includes(searchQuery)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 space-y-8 bg-slate-50/50 min-h-screen font-inter">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h2 className="text-xs font-black text-[#0f4475] uppercase tracking-widest">Ritz Section</h2>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-black text-slate-800">Manage Wallets</h1>
|
||||||
|
<p className="text-[10px] text-slate-400 font-medium flex items-center gap-1.5 mt-1">
|
||||||
|
<span className="p-0.5 bg-slate-200 rounded text-slate-500">i</span>
|
||||||
|
Admin panel for manual Ritz Token crediting and balance monitoring.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search user or mobile..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="bg-white border border-slate-200 rounded-xl pl-10 pr-4 py-2 text-sm outline-none focus:border-[#0f4475] transition-all w-64 shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="bg-white rounded-[32px] border border-slate-100 shadow-sm overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-50/50 border-b border-slate-100">
|
||||||
|
<th className="px-8 py-5 text-left text-[10px] font-black text-slate-400 uppercase tracking-widest">User ID</th>
|
||||||
|
<th className="px-8 py-5 text-left text-[10px] font-black text-slate-400 uppercase tracking-widest">Customer Details</th>
|
||||||
|
<th className="px-8 py-5 text-left text-[10px] font-black text-slate-400 uppercase tracking-widest">Mobile Number</th>
|
||||||
|
<th className="px-8 py-5 text-right text-[10px] font-black text-slate-400 uppercase tracking-widest">Wallet Balance</th>
|
||||||
|
<th className="px-8 py-5 text-center text-[10px] font-black text-slate-400 uppercase tracking-widest">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-50">
|
||||||
|
{isLoading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="py-20 text-center">
|
||||||
|
<motion.div
|
||||||
|
animate={{ scale: [1, 1.05, 1], opacity: [0.5, 1, 0.5] }}
|
||||||
|
transition={{ repeat: Infinity, duration: 1.5 }}
|
||||||
|
className="text-[10px] font-black text-[#0f4475] uppercase tracking-widest"
|
||||||
|
>
|
||||||
|
Loading Wallet Data...
|
||||||
|
</motion.div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : filteredUsers.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="py-20 text-center text-slate-400 text-sm font-medium">
|
||||||
|
No users found matching your search.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredUsers.map((user) => (
|
||||||
|
<tr key={user.id} className="hover:bg-slate-50/50 transition-colors group">
|
||||||
|
<td className="px-8 py-5 text-sm font-black text-slate-400 tabular-nums">#{user.id}</td>
|
||||||
|
<td className="px-8 py-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-[#0f4475]/5 flex items-center justify-center text-[#0f4475]">
|
||||||
|
<UserIcon size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-black text-slate-800 leading-none mb-1">{user.name || 'Anonymous User'}</p>
|
||||||
|
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Registered Member</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-5">
|
||||||
|
<div className="flex items-center gap-2 text-slate-600 font-bold text-sm">
|
||||||
|
<Phone size={14} className="text-slate-300" />
|
||||||
|
{user.mobileNumber}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-5 text-right">
|
||||||
|
<div className="inline-flex items-center gap-2 bg-emerald-50 px-4 py-2 rounded-xl">
|
||||||
|
<Wallet size={14} className="text-emerald-500" />
|
||||||
|
<span className="text-lg font-black text-emerald-600 tracking-tighter">R{user.ritzTokenBalance.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-5 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="bg-[#0f4475] text-white px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest hover:scale-105 transition-all shadow-lg shadow-[#0f4475]/10 active:scale-95"
|
||||||
|
>
|
||||||
|
Credit Wallet
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Credit Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isModalOpen && selectedUser && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-auto">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => !isProcessing && setIsModalOpen(false)}
|
||||||
|
className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||||
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
|
exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||||
|
className="relative w-[450px] bg-white rounded-[32px] shadow-2xl overflow-hidden border border-slate-100"
|
||||||
|
>
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-[#0f4475]/10 text-[#0f4475] rounded-xl">
|
||||||
|
<Plus size={20} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-black text-slate-800">Credit Ritz Tokens</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="p-2 hover:bg-slate-50 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} className="text-slate-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-50 p-4 rounded-2xl mb-8 border border-slate-100">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<UserIcon size={14} className="text-slate-400" />
|
||||||
|
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Target User</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-black text-slate-800">{selectedUser.name}</p>
|
||||||
|
<p className="text-xs font-bold text-slate-500">{selectedUser.mobileNumber}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleTopUp} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 mb-2 block">Token Amount (Ritz)</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute left-4 top-1/2 -translate-y-1/2 font-black text-[#0f4475] text-lg">R</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
className="w-full bg-slate-50 border border-slate-100 rounded-2xl pl-10 pr-4 py-4 text-2xl font-black text-slate-800 outline-none focus:bg-white focus:border-[#0f4475]/30 transition-all"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className={`p-4 rounded-2xl flex items-center gap-3 ${status.type === 'success' ? 'bg-emerald-50 text-emerald-600' : 'bg-rose-50 text-rose-600'}`}
|
||||||
|
>
|
||||||
|
{status.type === 'success' ? <CheckCircle2 size={18} /> : <AlertCircle size={18} />}
|
||||||
|
<span className="text-xs font-black uppercase tracking-tight leading-tight">{status.message}</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isProcessing || !amount}
|
||||||
|
className="w-full bg-[#0f4475] text-white py-4 rounded-2xl font-black uppercase tracking-widest shadow-xl shadow-[#0f4475]/20 hover:scale-[1.02] transition-all flex items-center justify-center gap-2 group disabled:opacity-50 disabled:hover:scale-100"
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Confirm Credit
|
||||||
|
<ArrowRight size={18} className="group-hover:translate-x-1 transition-transform" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ManageWallets;
|
||||||
@@ -41,16 +41,67 @@ const StoreDashboard = () => {
|
|||||||
return isNaN(num) ? '0' : num.toLocaleString();
|
return isNaN(num) ? '0' : num.toLocaleString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toLocalISOString = (date: Date) => {
|
||||||
|
const tzo = -date.getTimezoneOffset(),
|
||||||
|
pad = (num: number) => {
|
||||||
|
const norm = Math.floor(Math.abs(num));
|
||||||
|
return (norm < 10 ? '0' : '') + norm;
|
||||||
|
};
|
||||||
|
return date.getFullYear() +
|
||||||
|
'-' + pad(date.getMonth() + 1) +
|
||||||
|
'-' + pad(date.getDate()) +
|
||||||
|
'T' + pad(date.getHours()) +
|
||||||
|
':' + pad(date.getMinutes()) +
|
||||||
|
':' + pad(date.getSeconds()) +
|
||||||
|
'.' + pad(date.getMilliseconds());
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRangeDates = (range: string) => {
|
||||||
|
const now = new Date();
|
||||||
|
const start = new Date();
|
||||||
|
const end = new Date();
|
||||||
|
|
||||||
|
// Set end to end of today
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
switch (range) {
|
||||||
|
case 'Today':
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
case 'Yesterday':
|
||||||
|
start.setDate(now.getDate() - 1);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
end.setDate(now.getDate() - 1);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
break;
|
||||||
|
case 'Week':
|
||||||
|
start.setDate(now.getDate() - 7);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
case '30 Days':
|
||||||
|
start.setDate(now.getDate() - 30);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
return { from: toLocalISOString(start), to: toLocalISOString(end) };
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const response = await fetch('/api/dashboard/stats');
|
const range = getRangeDates(timeRange);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('from', range.from);
|
||||||
|
params.append('to', range.to);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/dashboard/stats?${params.toString()}`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log('Dashboard data received successfully:', data);
|
console.log('Dashboard data received successfully:', data);
|
||||||
|
|
||||||
// Set basic stats from general stats if available
|
|
||||||
if (data.stats) {
|
if (data.stats) {
|
||||||
setStats({
|
setStats({
|
||||||
totalSales: data.stats.totalSales,
|
totalSales: data.stats.totalSales,
|
||||||
@@ -60,7 +111,6 @@ const StoreDashboard = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override with RIT Canteen specific data if found in overview
|
|
||||||
if (data.storeOverview && data.storeOverview.length > 0) {
|
if (data.storeOverview && data.storeOverview.length > 0) {
|
||||||
const ritStore = data.storeOverview.find((s: any) => s.name === 'RIT Canteen');
|
const ritStore = data.storeOverview.find((s: any) => s.name === 'RIT Canteen');
|
||||||
if (ritStore) {
|
if (ritStore) {
|
||||||
@@ -68,12 +118,11 @@ const StoreDashboard = () => {
|
|||||||
...prev,
|
...prev,
|
||||||
totalSales: ritStore.sale,
|
totalSales: ritStore.sale,
|
||||||
activeOrders: ritStore.orders,
|
activeOrders: ritStore.orders,
|
||||||
dailyCustomers: ritStore.orders * 0.9, // Approximation
|
dailyCustomers: ritStore.orders * 0.9,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set other dynamic data
|
|
||||||
if (data.trendingItems) setTrendingItems(data.trendingItems);
|
if (data.trendingItems) setTrendingItems(data.trendingItems);
|
||||||
if (data.hourlySales) setSalesData(data.hourlySales);
|
if (data.hourlySales) setSalesData(data.hourlySales);
|
||||||
if (data.insights) setInsights(data.insights);
|
if (data.insights) setInsights(data.insights);
|
||||||
@@ -85,7 +134,7 @@ const StoreDashboard = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchStats();
|
fetchStats();
|
||||||
}, []);
|
}, [timeRange]);
|
||||||
|
|
||||||
const pieData = [
|
const pieData = [
|
||||||
{ name: 'Full Payment', value: Number(stats.totalSales) || 0, color: '#8b5cf6' },
|
{ name: 'Full Payment', value: Number(stats.totalSales) || 0, color: '#8b5cf6' },
|
||||||
|
|||||||
Reference in New Issue
Block a user