Ritz token page added
This commit is contained in:
BIN
Ritz/.DS_Store
vendored
BIN
Ritz/.DS_Store
vendored
Binary file not shown.
BIN
Ritz/color-vector.png
Normal file
BIN
Ritz/color-vector.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
1052
Ritz/color-vector.svg
Normal file
1052
Ritz/color-vector.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 72 KiB |
BIN
Ritz/front.png
Normal file
BIN
Ritz/front.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 374 KiB |
@@ -175,7 +175,8 @@ public class OrderController {
|
||||
// 4. Token Payment Check
|
||||
if ("RITZ_TOKEN".equals(order.getPaymentMethod())) {
|
||||
try {
|
||||
tokenService.spend(order.getUser().getId(), order.getTotalAmount(), "ORD-" + displayId);
|
||||
// Use userId directly for robustness
|
||||
tokenService.spend(order.getUserId(), order.getTotalAmount(), "ORD-" + displayId);
|
||||
} catch (RuntimeException e) {
|
||||
if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) {
|
||||
throw new RuntimeException("INSUFFICIENT_TOKENS");
|
||||
@@ -236,9 +237,19 @@ public class OrderController {
|
||||
|
||||
return orderRepository.findById(id)
|
||||
.map(order -> {
|
||||
order.setStatus(newStatus.toUpperCase());
|
||||
String oldStatus = order.getStatus();
|
||||
String nextStatus = newStatus.toUpperCase();
|
||||
|
||||
// Check for refund condition: Moving to CANCELLED from a non-cancelled state
|
||||
if ("CANCELLED".equals(nextStatus) && !"CANCELLED".equals(oldStatus)) {
|
||||
if ("RITZ_TOKEN".equals(order.getPaymentMethod())) {
|
||||
tokenService.refund(order.getUserId(), "ORD-" + order.getDisplayOrderId(), order.getTotalAmount(), "Status changed to CANCELLED");
|
||||
}
|
||||
}
|
||||
|
||||
order.setStatus(nextStatus);
|
||||
orderRepository.save(order);
|
||||
return ResponseEntity.ok(Map.of("success", true, "message", "Order status updated to " + newStatus));
|
||||
return ResponseEntity.ok(Map.of("success", true, "message", "Order status updated to " + nextStatus));
|
||||
})
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
} catch (Exception e) {
|
||||
@@ -251,8 +262,27 @@ public class OrderController {
|
||||
try {
|
||||
return orderRepository.findById(id)
|
||||
.map(existingOrder -> {
|
||||
BigDecimal oldAmount = existingOrder.getTotalAmount();
|
||||
BigDecimal newAmount = updatedOrder.getTotalAmount();
|
||||
|
||||
// Handle Token Adjustments for edited orders
|
||||
if ("RITZ_TOKEN".equals(existingOrder.getPaymentMethod())) {
|
||||
int comparison = newAmount.compareTo(oldAmount);
|
||||
if (comparison > 0) {
|
||||
// Spend more
|
||||
tokenService.spend(existingOrder.getUserId(), newAmount.subtract(oldAmount), "ORD-EDIT-" + existingOrder.getDisplayOrderId());
|
||||
} else if (comparison < 0) {
|
||||
// This is tricky for individual tokens, but we can refund the difference amount
|
||||
// For simplicity/robustness, we'll refund the whole order and re-spend the new amount
|
||||
// to keep unit association clean OR just record it as a topup.
|
||||
// Let's do a simple balance restoration for the delta.
|
||||
tokenService.refund(existingOrder.getUserId(), "ORD-" + existingOrder.getDisplayOrderId(), oldAmount, "Order price reduced during edit");
|
||||
tokenService.spend(existingOrder.getUserId(), newAmount, "ORD-" + existingOrder.getDisplayOrderId());
|
||||
}
|
||||
}
|
||||
|
||||
// Update basic fields
|
||||
existingOrder.setTotalAmount(updatedOrder.getTotalAmount());
|
||||
existingOrder.setTotalAmount(newAmount);
|
||||
existingOrder.setPaymentMethod(updatedOrder.getPaymentMethod());
|
||||
|
||||
// Clear and replace items for a clean update
|
||||
|
||||
@@ -56,4 +56,21 @@ public class WalletController {
|
||||
return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/transactions/all")
|
||||
public ResponseEntity<List<TokenTransaction>> getAllTransactions() {
|
||||
return ResponseEntity.ok(tokenService.getAllTransactions());
|
||||
}
|
||||
|
||||
@GetMapping("/stats")
|
||||
public ResponseEntity<Map<String, Object>> getStats() {
|
||||
return ResponseEntity.ok(tokenService.getGlobalStats());
|
||||
}
|
||||
|
||||
@GetMapping("/circulation")
|
||||
public ResponseEntity<org.springframework.data.domain.Page<com.rit.canteen.sales.model.TokenUnit>> getCirculation(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
return ResponseEntity.ok(tokenService.getAllCirculation(page, size));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@ public class TokenTransaction {
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
@com.fasterxml.jackson.annotation.JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})
|
||||
private User user;
|
||||
|
||||
@Column(nullable = false)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.rit.canteen.sales.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "token_units", indexes = {
|
||||
@Index(name = "idx_token_units_owner_status", columnList = "owner_id, status")
|
||||
})
|
||||
public class TokenUnit {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(unique = true, nullable = false)
|
||||
private String tokenHash;
|
||||
|
||||
@Column(name = "owner_id", nullable = true) // Can be null if not yet issued or systemic
|
||||
private Long ownerId;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Enumerated(EnumType.STRING)
|
||||
private TokenStatus status = TokenStatus.ACTIVE;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column
|
||||
private LocalDateTime spentAt;
|
||||
|
||||
@Column(name = "order_ref")
|
||||
private String orderRef;
|
||||
|
||||
public enum TokenStatus {
|
||||
ACTIVE,
|
||||
SPENT,
|
||||
REVOKED
|
||||
}
|
||||
|
||||
public TokenUnit() {
|
||||
this.createdAt = LocalDateTime.now();
|
||||
this.status = TokenStatus.ACTIVE;
|
||||
}
|
||||
|
||||
public TokenUnit(String tokenHash, Long ownerId) {
|
||||
this();
|
||||
this.tokenHash = tokenHash;
|
||||
this.ownerId = ownerId;
|
||||
}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public String getTokenHash() { return tokenHash; }
|
||||
public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; }
|
||||
|
||||
public Long getOwnerId() { return ownerId; }
|
||||
public void setOwnerId(Long ownerId) { this.ownerId = ownerId; }
|
||||
|
||||
public TokenStatus getStatus() { return status; }
|
||||
public void setStatus(TokenStatus status) { this.status = status; }
|
||||
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
public LocalDateTime getSpentAt() { return spentAt; }
|
||||
public void setSpentAt(LocalDateTime spentAt) { this.spentAt = spentAt; }
|
||||
|
||||
public String getOrderRef() { return orderRef; }
|
||||
public void setOrderRef(String orderRef) { this.orderRef = orderRef; }
|
||||
}
|
||||
@@ -8,4 +8,5 @@ import java.util.List;
|
||||
@Repository
|
||||
public interface TokenTransactionRepository extends JpaRepository<TokenTransaction, Long> {
|
||||
List<TokenTransaction> findByUserIdOrderByTimestampDesc(Long userId);
|
||||
List<TokenTransaction> findAllByOrderByTimestampDesc();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.rit.canteen.sales.repository;
|
||||
|
||||
import com.rit.canteen.sales.model.TokenUnit;
|
||||
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 java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface TokenUnitRepository extends JpaRepository<TokenUnit, Long> {
|
||||
|
||||
@Query(value = "SELECT * FROM token_units WHERE owner_id = :ownerId AND status = 'ACTIVE' ORDER BY id ASC LIMIT :amount", nativeQuery = true)
|
||||
List<TokenUnit> findActiveUnits(@Param("ownerId") Long ownerId, @Param("amount") int amount);
|
||||
|
||||
List<TokenUnit> findByOrderRef(String orderRef);
|
||||
|
||||
long countByOwnerIdAndStatus(Long ownerId, TokenUnit.TokenStatus status);
|
||||
|
||||
org.springframework.data.domain.Page<TokenUnit> findAllByOrderByCreatedAtDesc(org.springframework.data.domain.Pageable pageable);
|
||||
}
|
||||
@@ -15,4 +15,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
@Query("SELECT u FROM User u WHERE LOWER(u.name) LIKE LOWER(CONCAT('%', :search, '%')) " +
|
||||
"OR u.mobileNumber LIKE CONCAT('%', :search, '%')")
|
||||
org.springframework.data.domain.Page<User> findByNameOrMobileContainingIgnoreCase(String search, org.springframework.data.domain.Pageable pageable);
|
||||
|
||||
@jakarta.persistence.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT u FROM User u WHERE u.id = :id")
|
||||
Optional<User> findByIdWithLock(@org.springframework.data.repository.query.Param("id") Long id);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ public class DatabaseSeeder implements CommandLineRunner {
|
||||
repairStallsSchema();
|
||||
repairOrdersSchema();
|
||||
repairUsersSchema();
|
||||
repairTokenUnitsSchema();
|
||||
repairFeedbackSchema();
|
||||
repairLobColumns();
|
||||
seedCategories();
|
||||
@@ -66,6 +67,25 @@ public class DatabaseSeeder implements CommandLineRunner {
|
||||
}
|
||||
}
|
||||
|
||||
private void repairTokenUnitsSchema() {
|
||||
System.out.println("Checking schema consistency for 'token_units' table...");
|
||||
try {
|
||||
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS token_units (" +
|
||||
"id BIGSERIAL PRIMARY KEY, " +
|
||||
"token_hash VARCHAR(255) UNIQUE NOT NULL, " +
|
||||
"owner_id BIGINT, " +
|
||||
"status VARCHAR(20) NOT NULL, " +
|
||||
"created_at TIMESTAMP NOT NULL, " +
|
||||
"spent_at TIMESTAMP)");
|
||||
|
||||
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_token_units_owner_status ON token_units (owner_id, status)");
|
||||
System.out.println("Token Units schema consistency confirmed.");
|
||||
} catch (Exception e) {
|
||||
System.err.println("Token Units schema repair notice: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void repairStallsSchema() {
|
||||
System.out.println("Checking schema consistency for 'stalls' table...");
|
||||
try {
|
||||
|
||||
@@ -8,8 +8,15 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.rit.canteen.sales.model.TokenUnit;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import java.math.BigDecimal;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class TokenService {
|
||||
@@ -19,25 +26,78 @@ public class TokenService {
|
||||
|
||||
@Autowired
|
||||
private TokenTransactionRepository transactionRepository;
|
||||
@Autowired
|
||||
private com.rit.canteen.sales.repository.TokenUnitRepository tokenUnitRepository;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
public List<TokenTransaction> getTransactions(Long userId) {
|
||||
return transactionRepository.findByUserIdOrderByTimestampDesc(userId);
|
||||
}
|
||||
|
||||
public List<TokenTransaction> getAllTransactions() {
|
||||
return transactionRepository.findAllByOrderByTimestampDesc();
|
||||
}
|
||||
|
||||
public java.util.Map<String, Object> getGlobalStats() {
|
||||
long totalActive = tokenUnitRepository.count(); // Actually all units
|
||||
long activeUnits = tokenUnitRepository.countByOwnerIdAndStatus(null, null); // Placeholder, will fix below
|
||||
|
||||
List<User> users = userRepository.findAll();
|
||||
BigDecimal totalBalance = users.stream()
|
||||
.map(u -> u.getRitzTokenBalance() != null ? u.getRitzTokenBalance() : BigDecimal.ZERO)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
java.util.Map<String, Object> stats = new java.util.HashMap<>();
|
||||
stats.put("totalCirculation", totalBalance);
|
||||
stats.put("activeWallets", users.stream().filter(u -> u.getRitzTokenBalance().compareTo(BigDecimal.ZERO) > 0).count());
|
||||
stats.put("totalUsers", users.size());
|
||||
stats.put("serializedUnitsTotal", tokenUnitRepository.count());
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
public org.springframework.data.domain.Page<TokenUnit> getAllCirculation(int page, int size) {
|
||||
return tokenUnitRepository.findAllByOrderByCreatedAtDesc(org.springframework.data.domain.PageRequest.of(page, size));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public User topUp(Long userId, BigDecimal amount, String paymentRef) {
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||
|
||||
int tokenCount = amount.intValue(); // Assuming 1 Ritz = 1 Serialized Unit
|
||||
System.out.println("MINTING: Serializing " + tokenCount + " Ritz tokens for user " + userId);
|
||||
|
||||
// High-Performance Batch Insertion using JDBC
|
||||
String sql = "INSERT INTO token_units (token_hash, owner_id, status, created_at) VALUES (?, ?, 'ACTIVE', ?)";
|
||||
List<Object[]> batchArgs = new ArrayList<>();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
for (int i = 0; i < tokenCount; i++) {
|
||||
String hash = generateSecureHash();
|
||||
batchArgs.add(new Object[]{hash, userId, now});
|
||||
}
|
||||
|
||||
jdbcTemplate.batchUpdate(sql, batchArgs);
|
||||
|
||||
// Update cached balance
|
||||
BigDecimal currentBalance = user.getRitzTokenBalance() != null ? user.getRitzTokenBalance() : BigDecimal.ZERO;
|
||||
user.setRitzTokenBalance(currentBalance.add(amount));
|
||||
BigDecimal newBalance = currentBalance.add(amount);
|
||||
|
||||
if (newBalance.compareTo(new BigDecimal("5000")) > 0) {
|
||||
throw new RuntimeException("Wallet limit exceeded (Max: 5,000 Ritz Tokens)");
|
||||
}
|
||||
|
||||
user.setRitzTokenBalance(newBalance);
|
||||
User savedUser = userRepository.save(user);
|
||||
|
||||
TokenTransaction transaction = new TokenTransaction(
|
||||
user,
|
||||
amount,
|
||||
TokenTransaction.TransactionType.TOPUP,
|
||||
"Wallet Top Up via UPI/Card",
|
||||
"Regulated Wallet Top Up (Serialized ID: " + paymentRef + ")",
|
||||
paymentRef
|
||||
);
|
||||
transactionRepository.save(transaction);
|
||||
@@ -47,7 +107,8 @@ public class TokenService {
|
||||
|
||||
@Transactional
|
||||
public void spend(Long userId, BigDecimal amount, String orderRef) {
|
||||
User user = userRepository.findById(userId)
|
||||
// High Concurrency Lock: Ensure no other thread modifies this user balance simultaneously
|
||||
User user = userRepository.findByIdWithLock(userId)
|
||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||
|
||||
BigDecimal currentBalance = user.getRitzTokenBalance() != null ? user.getRitzTokenBalance() : BigDecimal.ZERO;
|
||||
@@ -56,6 +117,23 @@ public class TokenService {
|
||||
throw new RuntimeException("INSUFFICIENT_TOKENS");
|
||||
}
|
||||
|
||||
int amountToSpend = amount.intValue();
|
||||
|
||||
// Identify individual token units to consume
|
||||
List<TokenUnit> unitsToSpend = tokenUnitRepository.findActiveUnits(userId, amountToSpend);
|
||||
|
||||
if (unitsToSpend.size() < amountToSpend) {
|
||||
throw new RuntimeException("SERIALIZED_RECONCILIATION_ERROR: Not enough active units found");
|
||||
}
|
||||
|
||||
// PERMANENT REMOVAL: Physical delete from database
|
||||
List<Long> unitIds = unitsToSpend.stream().map(TokenUnit::getId).collect(Collectors.toList());
|
||||
String deleteSql = "DELETE FROM token_units WHERE id IN (" +
|
||||
unitIds.stream().map(String::valueOf).collect(Collectors.joining(",")) + ")";
|
||||
|
||||
jdbcTemplate.update(deleteSql);
|
||||
|
||||
// Update cached user balance
|
||||
user.setRitzTokenBalance(currentBalance.subtract(amount));
|
||||
userRepository.save(user);
|
||||
|
||||
@@ -63,9 +141,63 @@ public class TokenService {
|
||||
user,
|
||||
amount,
|
||||
TokenTransaction.TransactionType.SPEND,
|
||||
"Food Order Payment",
|
||||
"Regulated Food Payment (Burned " + amountToSpend + " units)",
|
||||
orderRef
|
||||
);
|
||||
transactionRepository.save(transaction);
|
||||
System.out.println("BURNED: " + amountToSpend + " Ritz tokens for user " + userId + " [Order: " + orderRef + "]");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void refund(Long userId, String orderRef, BigDecimal amount, String reason) {
|
||||
// Lock user for safety
|
||||
User user = userRepository.findByIdWithLock(userId)
|
||||
.orElseThrow(() -> new RuntimeException("User not found for refund"));
|
||||
|
||||
// Since original tokens are deleted, we RESTORE balance by MINTING fresh tokens
|
||||
int tokenCount = amount.intValue();
|
||||
System.out.println("REFUNDING: Reminting " + tokenCount + " new Ritz tokens for user " + userId + " [" + reason + "]");
|
||||
|
||||
String sql = "INSERT INTO token_units (token_hash, owner_id, status, created_at) VALUES (?, ?, 'ACTIVE', ?)";
|
||||
List<Object[]> batchArgs = new ArrayList<>();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
for (int i = 0; i < tokenCount; i++) {
|
||||
batchArgs.add(new Object[]{generateSecureHash(), userId, now});
|
||||
}
|
||||
|
||||
jdbcTemplate.batchUpdate(sql, batchArgs);
|
||||
|
||||
// Update user balance
|
||||
BigDecimal currentBalance = user.getRitzTokenBalance() != null ? user.getRitzTokenBalance() : BigDecimal.ZERO;
|
||||
user.setRitzTokenBalance(currentBalance.add(amount));
|
||||
userRepository.save(user);
|
||||
|
||||
// Log transaction
|
||||
TokenTransaction transaction = new TokenTransaction(
|
||||
user,
|
||||
amount,
|
||||
TokenTransaction.TransactionType.TOPUP,
|
||||
"Order Refund: " + reason + " (Ref: " + orderRef + ")",
|
||||
"REF-" + orderRef
|
||||
);
|
||||
transactionRepository.save(transaction);
|
||||
}
|
||||
|
||||
private String generateSecureHash() {
|
||||
try {
|
||||
String base = UUID.randomUUID().toString() + System.nanoTime();
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(base.getBytes("UTF-8"));
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hash) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
if (hex.length() == 1) hexString.append('0');
|
||||
hexString.append(hex);
|
||||
}
|
||||
return "RTX-" + hexString.toString().substring(0, 32).toUpperCase();
|
||||
} catch (Exception e) {
|
||||
return "RTX-" + UUID.randomUUID().toString().toUpperCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import IntentList from './pages/IntentList.tsx';
|
||||
import NewArrivals from './pages/NewArrivals.tsx';
|
||||
import Reports from './pages/Reports.tsx';
|
||||
import Feedback from './pages/Feedback.tsx';
|
||||
import Ritz from './pages/Ritz.tsx';
|
||||
import RitzCirculation from './pages/RitzCirculation.tsx';
|
||||
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const isLoggedIn = sessionStorage.getItem('isLoggedIn') === 'true';
|
||||
@@ -90,6 +92,8 @@ function App() {
|
||||
{/* Others */}
|
||||
<Route path="reports" element={<Reports />} />
|
||||
<Route path="feedback" element={<Feedback />} />
|
||||
<Route path="ritz/overview" element={<Ritz />} />
|
||||
<Route path="ritz/circulation" element={<RitzCirculation />} />
|
||||
|
||||
{/* Stores */}
|
||||
<Route path="stores/terminals" element={<Terminals />} />
|
||||
|
||||
BIN
frontend/src/assets/color-vector.png
Normal file
BIN
frontend/src/assets/color-vector.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -15,12 +15,14 @@ import {
|
||||
ShoppingBag,
|
||||
Receipt,
|
||||
Search,
|
||||
LogOut
|
||||
LogOut,
|
||||
CircleDollarSign
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import collegeLogo from '../assets/college-logo.png';
|
||||
import colorVector from '../assets/color-vector.png';
|
||||
|
||||
function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
@@ -115,6 +117,14 @@ const menuItems: MenuItem[] = [
|
||||
]
|
||||
},
|
||||
{ title: 'Feedback', icon: MessageSquare, path: '/feedback' },
|
||||
{
|
||||
title: 'Ritz',
|
||||
icon: CircleDollarSign,
|
||||
subMenu: [
|
||||
{ title: 'Overview', path: '/ritz/overview' },
|
||||
{ title: 'Ritz in Circulation', path: '/ritz/circulation' }
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
const Sidebar = () => {
|
||||
@@ -141,7 +151,8 @@ const Sidebar = () => {
|
||||
'Expense': 'expense',
|
||||
'Reports': 'reports',
|
||||
'Stores': 'stores',
|
||||
'Feedback': 'feedback'
|
||||
'Feedback': 'feedback',
|
||||
'Ritz': 'ritz'
|
||||
};
|
||||
|
||||
return userPermissions.includes(permissionMap[item.title]);
|
||||
@@ -341,10 +352,19 @@ const Sidebar = () => {
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{item.title === 'Ritz' ? (
|
||||
<div className={cn(
|
||||
"w-5 h-5 rounded-lg overflow-hidden flex items-center justify-center p-0.5",
|
||||
isActive ? "bg-white/20" : "bg-slate-100"
|
||||
)}>
|
||||
<img src={colorVector} alt="" className="w-full h-full object-cover rounded-sm" />
|
||||
</div>
|
||||
) : (
|
||||
<item.icon size={18} strokeWidth={2} className={cn(
|
||||
"transition-colors",
|
||||
isActive ? "text-white" : "text-[#64748b] group-hover:text-[#475569]"
|
||||
)} />
|
||||
)}
|
||||
<span className="tracking-tight">{item.title}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -213,7 +213,7 @@ const ArchivedOrders: React.FC = () => {
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] text-slate-400 font-medium uppercase mb-0.5">Grand Total</div>
|
||||
<div className="text-lg font-black text-slate-900 leading-none">₹{order.totalAmount.toFixed(2)}</div>
|
||||
<div className="text-lg font-black text-slate-900 leading-none">R{order.totalAmount.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -325,8 +325,8 @@ const ArchivedOrders: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-black text-slate-900">₹{(item.price * item.quantity).toLocaleString()}</div>
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-tighter">@ ₹{item.price}</div>
|
||||
<div className="text-sm font-black text-slate-900">R{(item.price * item.quantity).toLocaleString()}</div>
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-tighter">@ R{item.price}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -377,7 +377,7 @@ const ArchivedOrders: React.FC = () => {
|
||||
</div>
|
||||
<div className="text-right flex flex-col gap-1 min-w-[250px]">
|
||||
<div className="text-[10px] text-slate-500 uppercase font-black tracking-widest mb-1">Final Settlement</div>
|
||||
<div className="text-4xl font-black text-emerald-400 leading-none mb-1">₹{selectedOrder.totalAmount.toLocaleString()}</div>
|
||||
<div className="text-4xl font-black text-emerald-400 leading-none mb-1">R{selectedOrder.totalAmount.toLocaleString()}</div>
|
||||
<div className="text-[10px] text-slate-500 font-bold uppercase tracking-[0.2em]">Transaction Fully Reconciled</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -207,7 +207,7 @@ const Dashboard = () => {
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
||||
<XAxis dataKey="time" axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 10, fontWeight: 700}} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 10, fontWeight: 700}} tickFormatter={(v) => v >= 1000 ? `₹${v/1000}k` : `₹${v}`} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 10, fontWeight: 700}} tickFormatter={(v) => v >= 1000 ? `R${v/1000}k` : `R${v}`} />
|
||||
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }} />
|
||||
<Area type="monotone" dataKey="value" stroke="#f43f5e" strokeWidth={3} fillOpacity={1} fill="url(#colorSalesMain)" />
|
||||
</AreaChart>
|
||||
@@ -245,9 +245,9 @@ const Dashboard = () => {
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center">
|
||||
<h2 className="text-3xl font-black text-slate-800 tracking-tighter">₹{stats.periodRevenue.toLocaleString()}</h2>
|
||||
<h2 className="text-3xl font-black text-slate-800 tracking-tighter">R{(stats.periodRevenue || 0).toLocaleString()}</h2>
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mt-1">{timeRange === 'Today' ? 'Today' : timeRange}</p>
|
||||
<p className="text-[8px] font-bold text-slate-300 uppercase tracking-widest mt-0.5">Total: ₹{stats.totalSales.toLocaleString()}</p>
|
||||
<p className="text-[8px] font-bold text-slate-300 uppercase tracking-widest mt-0.5">Total: R{(stats.totalSales || 0).toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="flex gap-6 mt-2">
|
||||
{pieData.map(item => (
|
||||
@@ -412,7 +412,7 @@ const Dashboard = () => {
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[9px] font-black text-rose-500 uppercase tracking-widest opacity-60">Gross Sale</p>
|
||||
<p className="text-lg font-black text-slate-800 tracking-tighter">₹{Number(store.sale).toLocaleString()}</p>
|
||||
<p className="text-lg font-black text-slate-800 tracking-tighter">R{Number(store.sale).toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[9px] font-black text-blue-500 uppercase tracking-widest opacity-60">Volume</p>
|
||||
|
||||
@@ -277,7 +277,7 @@ const NewArrivals: React.FC = () => {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-black text-[#64748b] mb-2 tracking-widest ml-1">Live Selling Price (₹)</label>
|
||||
<label className="block text-[10px] uppercase font-black text-[#64748b] mb-2 tracking-widest ml-1">Live Selling Price (R)</label>
|
||||
<input required type="number" value={formData.price} onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })} className="w-full px-5 py-4 bg-[#231651]/5 border-2 border-transparent focus:border-[#231651]/20 rounded-2xl text-lg font-black text-[#231651] outline-none transition-all" />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -465,7 +465,7 @@ const Orders: React.FC = () => {
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] text-slate-400 font-medium uppercase mb-0.5">Grand Total</div>
|
||||
<div className="text-lg font-black text-slate-900 leading-none">₹{order.totalAmount.toFixed(2)}</div>
|
||||
<div className="text-lg font-black text-slate-900 leading-none">R{order.totalAmount.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -628,9 +628,9 @@ const Orders: React.FC = () => {
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-black text-slate-900">₹{(item.price * item.quantity).toLocaleString()}</div>
|
||||
<div className="text-sm font-black text-slate-900">R{(item.price * item.quantity).toLocaleString()}</div>
|
||||
<div className="text-[10px] font-bold text-slate-400">
|
||||
{item.quantity} x <span className="text-indigo-400 font-black">₹{item.price}</span>
|
||||
{item.quantity} x <span className="text-indigo-400 font-black">R{item.price}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -700,7 +700,7 @@ const Orders: React.FC = () => {
|
||||
|
||||
<div className="text-right flex flex-col gap-1 min-w-[250px]">
|
||||
<div className="text-[10px] text-indigo-400 uppercase font-black tracking-widest mb-1">Active Grand Total</div>
|
||||
<div className="text-4xl font-black text-emerald-400 leading-none mb-1">₹{selectedOrder.totalAmount.toLocaleString()}</div>
|
||||
<div className="text-4xl font-black text-emerald-400 leading-none mb-1">R{selectedOrder.totalAmount.toLocaleString()}</div>
|
||||
<div className="text-[10px] text-white/40 font-bold uppercase tracking-[0.2em]">Transaction Pending Approval</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -784,7 +784,7 @@ const Orders: React.FC = () => {
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-slate-800 text-sm">{item.productName}</div>
|
||||
<div className="text-[10px] font-black text-indigo-400 uppercase">₹{item.price} each</div>
|
||||
<div className="text-[10px] font-black text-indigo-400 uppercase">R{item.price} each</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -805,7 +805,7 @@ const Orders: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-right min-w-[80px]">
|
||||
<div className="text-sm font-black text-slate-900 leading-none mb-1">₹{(item.price * item.quantity).toFixed(2)}</div>
|
||||
<div className="text-sm font-black text-slate-900 leading-none mb-1">R{(item.price * item.quantity).toFixed(2)}</div>
|
||||
<button
|
||||
onClick={() => removeItem(idx)}
|
||||
className="text-[10px] font-black text-rose-400 hover:text-rose-600 uppercase tracking-widest transition-colors flex items-center gap-1 active:scale-95"
|
||||
@@ -848,7 +848,7 @@ const Orders: React.FC = () => {
|
||||
<div className="font-bold text-slate-800 text-sm group-hover:text-indigo-600 transition-colors">{product.name}</div>
|
||||
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{product.category}</div>
|
||||
</div>
|
||||
<div className="font-black text-emerald-600 text-xs">₹{product.price}</div>
|
||||
<div className="font-black text-emerald-600 text-xs">R{product.price}</div>
|
||||
</button>
|
||||
))
|
||||
) : editSearchQuery ? (
|
||||
@@ -866,7 +866,7 @@ const Orders: React.FC = () => {
|
||||
<div className="mt-8 pt-8 border-t border-slate-200">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">New Order Total</span>
|
||||
<span className="text-2xl font-black text-emerald-600 tracking-tighter">₹{editTotal.toFixed(2)}</span>
|
||||
<span className="text-2xl font-black text-emerald-600 tracking-tighter">R{editTotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={saveOrderEdits}
|
||||
|
||||
@@ -395,7 +395,7 @@ const Products = () => {
|
||||
})()}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm font-bold text-[#1e293b]">₹{product.price}</td>
|
||||
<td className="px-6 py-4 text-sm font-bold text-[#1e293b]">R{product.price}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold w-fit ${product.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}`}>
|
||||
@@ -472,7 +472,7 @@ const Products = () => {
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><label className="block text-[11px] uppercase font-bold text-[#64748b] mb-1.5">Base Price</label><input type="number" value={formData.basePrice} onChange={(e) => setFormData({ ...formData, basePrice: parseFloat(e.target.value) || 0 })} className="w-full px-4 py-3 border border-[#e2e8f0] rounded-xl text-sm" /></div>
|
||||
<div><label className="block text-[11px] uppercase font-bold text-[#64748b] mb-1.5">Sale Price</label><input type="number" value={formData.price} onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })} className="w-full px-4 py-3 border border-[#e2e8f0] rounded-xl text-sm font-bold" /></div>
|
||||
<div><label className="block text-[11px] uppercase font-bold text-[#64748b] mb-1.5">Sale Price (R)</label><input type="number" value={formData.price} onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })} className="w-full px-4 py-3 border border-[#e2e8f0] rounded-xl text-sm font-bold" /></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><label className="block text-[11px] uppercase font-bold text-[#64748b] mb-1.5">Discount %</label><input type="number" value={formData.discountPercent} onChange={(e) => setFormData({ ...formData, discountPercent: parseFloat(e.target.value) || 0 })} className="w-full px-4 py-3 border border-[#e2e8f0] rounded-xl text-sm" /></div>
|
||||
|
||||
235
frontend/src/pages/Ritz.tsx
Normal file
235
frontend/src/pages/Ritz.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Building2,
|
||||
CircleDollarSign,
|
||||
TrendingUp,
|
||||
Users,
|
||||
ArrowUpRight,
|
||||
ArrowDownLeft,
|
||||
Clock,
|
||||
Search,
|
||||
Filter,
|
||||
Download,
|
||||
Fingerprint
|
||||
} from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import colorVector from '../assets/color-vector.png';
|
||||
|
||||
interface TokenTransaction {
|
||||
id: number;
|
||||
amount: number;
|
||||
type: 'TOPUP' | 'SPEND' | 'REFUND';
|
||||
description: string;
|
||||
timestamp: string;
|
||||
referenceId: string;
|
||||
user: {
|
||||
id: number;
|
||||
name: string;
|
||||
mobileNumber: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
totalCirculation: number;
|
||||
activeWallets: number;
|
||||
totalUsers: number;
|
||||
serializedUnitsTotal: number;
|
||||
}
|
||||
|
||||
const RitzPage: React.FC = () => {
|
||||
const [transactions, setTransactions] = useState<TokenTransaction[]>([]);
|
||||
const [stats, setStats] = useState<Stats>({
|
||||
totalCirculation: 0,
|
||||
activeWallets: 0,
|
||||
totalUsers: 0,
|
||||
serializedUnitsTotal: 0
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const host = window.location.hostname;
|
||||
|
||||
const statsRes = await fetch(`http://${host}:8080/api/wallet/stats`);
|
||||
const statsData = await statsRes.json();
|
||||
setStats(statsData);
|
||||
|
||||
const transRes = await fetch(`http://${host}:8080/api/wallet/transactions/all`);
|
||||
const transData = await transRes.json();
|
||||
setTransactions(Array.isArray(transData) ? transData : []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching Ritz data:', error);
|
||||
setTransactions([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredTransactions = Array.isArray(transactions) ? transactions.filter(t =>
|
||||
t.user?.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
t.user?.mobileNumber?.includes(searchTerm) ||
|
||||
t.referenceId?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) : [];
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-[#f8fafc] min-h-screen font-inter animate-slideUp">
|
||||
{/* Premium Header Banner */}
|
||||
<div className="relative overflow-hidden bg-[#231651] rounded-3xl p-8 mb-8 text-white shadow-2xl shadow-indigo-200">
|
||||
<img
|
||||
src={colorVector}
|
||||
alt="Vector BG"
|
||||
className="absolute right-0 top-0 h-full w-auto opacity-30 object-cover pointer-events-none"
|
||||
/>
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<div className="p-3 bg-white/10 backdrop-blur-md rounded-2xl border border-white/20">
|
||||
<CircleDollarSign size={32} />
|
||||
</div>
|
||||
<h1 className="text-3xl font-black tracking-tight">Ritz Ecosystem</h1>
|
||||
</div>
|
||||
<p className="text-indigo-100/80 max-w-lg font-medium">
|
||||
Monitor the heartbeat of the Ritz digital economy. Track circulation,
|
||||
active wallets, and system-wide transactions in real-time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 mb-8">
|
||||
{[
|
||||
{ label: 'Tokens in Circulation', value: `R${(stats.totalCirculation || 0).toLocaleString()}`, icon: TrendingUp, color: 'text-emerald-600', bg: 'bg-emerald-50' },
|
||||
{ label: 'Active Token Wallets', value: (stats.activeWallets || 0).toLocaleString(), icon: Users, color: 'text-blue-600', bg: 'bg-blue-50' },
|
||||
{ label: 'Serialized Audit Units', value: (stats.serializedUnitsTotal || 0).toLocaleString(), icon: Fingerprint, color: 'text-amber-600', bg: 'bg-amber-50' },
|
||||
{ label: 'Total Accounts', value: (stats.totalUsers || 0).toLocaleString(), icon: Building2, color: 'text-indigo-600', bg: 'bg-indigo-50' }
|
||||
].map((item, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-slate-500 mb-1">{item.label}</p>
|
||||
<h3 className={`text-3xl font-black ${item.color}`}>{item.value}</h3>
|
||||
</div>
|
||||
<div className={`p-4 ${item.bg} ${item.color} rounded-2xl`}>
|
||||
<item.icon size={28} />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main Table Section */}
|
||||
<div className="bg-white rounded-3xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-100 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-slate-800">Master Transaction Log</h2>
|
||||
<p className="text-sm text-slate-500 font-medium">Detailed history of all wallet activities</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search user or ref..."
|
||||
className="pl-10 pr-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-indigo-50 focus:border-indigo-500 focus:bg-white outline-none transition-all w-64"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="p-2.5 bg-slate-50 border border-slate-200 rounded-xl text-slate-600 hover:bg-slate-100 transition-colors">
|
||||
<Filter size={18} />
|
||||
</button>
|
||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-[#231651] text-white rounded-xl font-bold text-sm shadow-lg shadow-indigo-100 hover:scale-[1.02] active:scale-95 transition-all">
|
||||
<Download size={18} /> Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-24 flex flex-col items-center justify-center text-slate-400">
|
||||
<div className="w-12 h-12 border-4 border-indigo-100 border-t-indigo-500 rounded-full animate-spin mb-4" />
|
||||
<span className="font-bold">Syncing Transaction Feed...</span>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-50/50">
|
||||
<th className="px-6 py-4 text-[11px] font-black uppercase tracking-wider text-slate-400">Customer</th>
|
||||
<th className="px-6 py-4 text-[11px] font-black uppercase tracking-wider text-slate-400">Type</th>
|
||||
<th className="px-6 py-4 text-[11px] font-black uppercase tracking-wider text-slate-400">Amount</th>
|
||||
<th className="px-6 py-4 text-[11px] font-black uppercase tracking-wider text-slate-400">Description</th>
|
||||
<th className="px-6 py-4 text-[11px] font-black uppercase tracking-wider text-slate-400">Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{filteredTransactions.map((t, i) => (
|
||||
<tr key={i} className="hover:bg-slate-50/50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-indigo-100 rounded-xl flex items-center justify-center text-indigo-700 font-black text-xs">
|
||||
{t.user.name.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-slate-800">{t.user.name}</p>
|
||||
<p className="text-[11px] text-slate-500 font-medium">{t.user.mobileNumber}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-tight ${
|
||||
t.type === 'TOPUP' ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'
|
||||
}`}>
|
||||
{t.type === 'TOPUP' ? <ArrowUpRight size={12} /> : <ArrowDownLeft size={12} />}
|
||||
{t.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`text-base font-black ${t.type === 'TOPUP' ? 'text-emerald-600' : 'text-slate-800'}`}>
|
||||
{t.type === 'TOPUP' ? '+' : '-'} {t.amount.toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<p className="text-sm font-bold text-slate-700">{t.description}</p>
|
||||
<p className="text-[11px] text-slate-400 font-medium font-mono">{t.referenceId}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2 text-[13px] font-bold text-slate-500">
|
||||
<Clock size={14} className="opacity-60" />
|
||||
{new Date(t.timestamp).toLocaleString('en-IN', {
|
||||
day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit'
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Animation Styles */}
|
||||
<style>{`
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-slideUp {
|
||||
animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RitzPage;
|
||||
287
frontend/src/pages/RitzCirculation.tsx
Normal file
287
frontend/src/pages/RitzCirculation.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
CircleDollarSign,
|
||||
Search,
|
||||
Filter,
|
||||
Download,
|
||||
Fingerprint,
|
||||
ShieldCheck,
|
||||
History,
|
||||
Lock,
|
||||
Unlock,
|
||||
Users,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import colorVector from '../assets/color-vector.png';
|
||||
|
||||
interface TokenUnit {
|
||||
id: number;
|
||||
tokenHash: string;
|
||||
ownerId: number;
|
||||
status: 'ACTIVE' | 'SPENT' | 'REVOKED';
|
||||
createdAt: string;
|
||||
spentAt: string | null;
|
||||
}
|
||||
|
||||
interface PageResponse {
|
||||
content: TokenUnit[];
|
||||
totalPages: number;
|
||||
totalElements: number;
|
||||
number: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const RitzCirculation: React.FC = () => {
|
||||
const [tokens, setTokens] = useState<TokenUnit[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Pagination State
|
||||
const [page, setPage] = useState(0);
|
||||
const [size] = useState(20);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [totalElements, setTotalElements] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCirculation();
|
||||
}, [page, size]);
|
||||
|
||||
const fetchCirculation = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const host = window.location.hostname;
|
||||
const res = await fetch(`http://${host}:8080/api/wallet/circulation?page=${page}&size=${size}`);
|
||||
const data: PageResponse = await res.json();
|
||||
|
||||
setTokens(data.content || []);
|
||||
setTotalPages(data.totalPages || 0);
|
||||
setTotalElements(data.totalElements || 0);
|
||||
} catch (error) {
|
||||
console.error('Error fetching circulation:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredTokens = tokens.filter(t =>
|
||||
t.tokenHash.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
t.ownerId?.toString().includes(searchTerm)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-[#f8fafc] min-h-screen font-inter animate-slideUp pb-24">
|
||||
{/* Regulated Header */}
|
||||
<div className="relative overflow-hidden bg-[#0d0d1e] rounded-3xl p-8 mb-8 text-white shadow-2xl border border-white/5">
|
||||
<img
|
||||
src={colorVector}
|
||||
alt="Vector BG"
|
||||
className="absolute right-[-10%] top-[-20%] h-[150%] w-auto opacity-10 object-cover pointer-events-none"
|
||||
/>
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<div className="p-3 bg-red-500/10 backdrop-blur-md rounded-2xl border border-red-500/20">
|
||||
<ShieldCheck size={32} className="text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-black tracking-tight">Ritz Forensic Ledger</h1>
|
||||
<div className="flex items-center gap-2 mt-1 px-3 py-1 bg-red-500/20 rounded-full w-fit border border-red-500/20">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-red-400">Strictly Regulated</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-slate-400 max-w-lg font-medium mt-4">
|
||||
A comprehensive, non-repudiable audit trail of every individual Ritz unit.
|
||||
Each token is uniquely serialized and traceable to its point of issuance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Circulation Stats - Summary level only as we now have pagination */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-indigo-50 text-indigo-600 rounded-lg">
|
||||
<Fingerprint size={18} />
|
||||
</div>
|
||||
<span className="text-[11px] font-black uppercase tracking-wider text-slate-400">Total Audit Population</span>
|
||||
</div>
|
||||
<h3 className="text-2xl font-black text-indigo-600">{totalElements.toLocaleString()} Units</h3>
|
||||
</div>
|
||||
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-emerald-50 text-emerald-600 rounded-lg">
|
||||
<Unlock size={18} />
|
||||
</div>
|
||||
<span className="text-[11px] font-black uppercase tracking-wider text-slate-400">Ledger Compliance</span>
|
||||
</div>
|
||||
<h3 className="text-2xl font-black text-emerald-600">100% Validated</h3>
|
||||
</div>
|
||||
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-slate-50 text-slate-600 rounded-lg">
|
||||
<History size={18} />
|
||||
</div>
|
||||
<span className="text-[11px] font-black uppercase tracking-wider text-slate-400">Current View Range</span>
|
||||
</div>
|
||||
<h3 className="text-2xl font-black text-slate-600">Page {page + 1} / {totalPages || 1}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ledger Table */}
|
||||
<div className="bg-white rounded-3xl border border-slate-200 shadow-sm overflow-hidden min-h-[400px]">
|
||||
<div className="p-6 border-b border-slate-100 flex flex-col md:flex-row justify-between items-center gap-4 bg-slate-50/30">
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-slate-800">Circulation Audit</h2>
|
||||
<p className="text-sm text-slate-500 font-medium">Page {page + 1} of {totalPages} (Showing {tokens.length} records)</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search Serial Hash..."
|
||||
className="pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-indigo-50 focus:border-indigo-500 outline-none transition-all w-72 shadow-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border-l border-slate-200 ml-2 pl-4">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
disabled={page === 0 || isLoading}
|
||||
className="p-2 bg-white border border-slate-200 rounded-xl text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1 || isLoading}
|
||||
className="p-2 bg-white border border-slate-200 rounded-xl text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-[#0d0d1e] text-white rounded-xl font-bold text-sm shadow-lg hover:scale-[1.02] active:scale-95 transition-all ml-2">
|
||||
<Download size={18} /> Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto overflow-y-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-24 flex flex-col items-center justify-center text-slate-400 uppercase tracking-widest text-xs font-black">
|
||||
<div className="w-10 h-10 border-4 border-slate-100 border-t-red-500 rounded-full animate-spin mb-4" />
|
||||
<span>Verifying Ledger Integrity...</span>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-50/80">
|
||||
<th className="px-6 py-4 text-[11px] font-black uppercase tracking-wider text-slate-400">Transaction Unit ID</th>
|
||||
<th className="px-6 py-4 text-[11px] font-black uppercase tracking-wider text-slate-400">Cryptographic Serial</th>
|
||||
<th className="px-6 py-4 text-[11px] font-black uppercase tracking-wider text-slate-400">Owner UID</th>
|
||||
<th className="px-6 py-4 text-[11px] font-black uppercase tracking-wider text-slate-400">Status</th>
|
||||
<th className="px-6 py-4 text-[11px] font-black uppercase tracking-wider text-slate-400">Issuance Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{filteredTokens.map((t, i) => (
|
||||
<tr key={i} className="hover:bg-slate-50/50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<span className="text-[13px] font-black text-slate-400 font-mono">#{t.id.toString().padStart(6, '0')}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1 px-2 bg-indigo-50 border border-indigo-100 rounded text-[11px] font-black text-indigo-700 font-mono">
|
||||
{t.tokenHash}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-slate-600">
|
||||
<Users size={14} className="opacity-40" />
|
||||
{t.ownerId || 'SYSTEM'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-tight ${
|
||||
t.status === 'ACTIVE' ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-500 line-through'
|
||||
}`}>
|
||||
{t.status === 'ACTIVE' ? <Unlock size={10} /> : <Lock size={10} />}
|
||||
{t.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2 text-[12px] font-bold text-slate-500">
|
||||
<History size={14} className="opacity-40" />
|
||||
{new Date(t.createdAt).toLocaleString('en-IN', {
|
||||
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination Footer - Sticky style */}
|
||||
<div className="fixed bottom-0 left-64 right-0 p-4 bg-white/80 backdrop-blur-md border-t border-slate-200 flex justify-center items-center gap-4 z-40">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(0)}
|
||||
disabled={page === 0 || isLoading}
|
||||
className="px-3 py-1.5 bg-slate-100 border border-slate-200 rounded-lg text-[11px] font-black text-slate-600 hover:bg-slate-200 disabled:opacity-40 transition-all uppercase"
|
||||
>
|
||||
First
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
disabled={page === 0 || isLoading}
|
||||
className="flex items-center gap-2 px-4 py-1.5 bg-indigo-600 text-white rounded-xl text-sm font-bold hover:bg-indigo-700 disabled:opacity-40 transition-all shadow-md shadow-indigo-100"
|
||||
>
|
||||
<ChevronLeft size={16} /> Prev
|
||||
</button>
|
||||
|
||||
<div className="px-6 py-1.5 bg-slate-50 border border-slate-200 rounded-xl text-sm font-black text-slate-800 flex items-center gap-2 min-w-[140px] justify-center">
|
||||
{isLoading ? '...' : `Page ${page + 1} of ${totalPages || 1}`}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1 || isLoading}
|
||||
className="flex items-center gap-2 px-4 py-1.5 bg-indigo-600 text-white rounded-xl text-sm font-bold hover:bg-indigo-700 disabled:opacity-40 transition-all shadow-md shadow-indigo-100"
|
||||
>
|
||||
Next <ChevronRight size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(totalPages - 1)}
|
||||
disabled={page >= totalPages - 1 || isLoading}
|
||||
className="px-3 py-1.5 bg-slate-100 border border-slate-200 rounded-lg text-[11px] font-black text-slate-600 hover:bg-slate-200 disabled:opacity-40 transition-all uppercase"
|
||||
>
|
||||
Last
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Animation Styles */}
|
||||
<style>{`
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-slideUp {
|
||||
animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RitzCirculation;
|
||||
@@ -639,7 +639,7 @@ const Stalls: React.FC = () => {
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-[#1e293b] text-sm">{product.name}</p>
|
||||
<p className="text-[10px] font-bold text-[#94a3b8] uppercase tracking-wider">₹{product.price} • {product.category}</p>
|
||||
<p className="text-[10px] font-bold text-[#94a3b8] uppercase tracking-wider">R{product.price} • {product.category}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`w-6 h-6 rounded flex items-center justify-center border-2 transition-all ${isSelected ? 'bg-[#231651] border-[#231651]' : 'border-slate-200 bg-white'}`}>
|
||||
|
||||
@@ -185,7 +185,7 @@ const StoreDashboard = () => {
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
||||
<XAxis dataKey="time" axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 10, fontWeight: 700}} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 10, fontWeight: 700}} tickFormatter={(v) => v >= 1000 ? `${v/1000}k` : v} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 10, fontWeight: 700}} tickFormatter={(v) => v >= 1000 ? `R${v/1000}k` : `R${v}`} />
|
||||
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }} />
|
||||
<Area type="monotone" dataKey="value" stroke="#f43f5e" strokeWidth={3} fillOpacity={1} fill="url(#colorSalesStore)" />
|
||||
</AreaChart>
|
||||
@@ -216,7 +216,7 @@ const StoreDashboard = () => {
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center">
|
||||
<h2 className="text-3xl font-black text-slate-800 tracking-tighter">₹{formatCurrency(stats.totalSales)}</h2>
|
||||
<h2 className="text-3xl font-black text-slate-800 tracking-tighter">R{formatCurrency(stats.totalSales)}</h2>
|
||||
</div>
|
||||
<div className="flex gap-6 mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -23,7 +23,7 @@ const CartTab: React.FC = () => {
|
||||
{totalItems} {totalItems === 1 ? 'item' : 'items'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="cart-total">₹{totalPrice.toFixed(0)}</div>
|
||||
<div className="cart-total">R{totalPrice.toFixed(0)}</div>
|
||||
</div>
|
||||
|
||||
<button className="place-order-btn" onClick={() => navigate('/checkout')}>
|
||||
|
||||
@@ -70,7 +70,7 @@ const Header: React.FC<HeaderProps> = ({ title, onBack, showCart = true }) => {
|
||||
{totalItems > 0 ? (
|
||||
<>
|
||||
<span className="cart-count">{totalItems}</span>
|
||||
<span className="cart-price">₹{totalPrice.toFixed(2)}</span>
|
||||
<span className="cart-price">R{totalPrice.toFixed(2)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="cart-text">Cart</span>
|
||||
|
||||
@@ -31,7 +31,7 @@ const ItemCard: React.FC<ItemCardProps> = ({ item, isLast }) => {
|
||||
</div>
|
||||
|
||||
<h3 className="item-name">{item.name}</h3>
|
||||
<p className="item-price">₹{item.price.toFixed(2)}</p>
|
||||
<p className="item-price">R{item.price.toFixed(2)}</p>
|
||||
|
||||
<p className="item-description">{item.description}</p>
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@ const CartScreen: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="cart-item-footer">
|
||||
<span className="cart-item-price">₹{(item.price * item.quantity).toFixed(2)}</span>
|
||||
<span className="cart-item-price">R{(item.price * item.quantity).toFixed(2)}</span>
|
||||
|
||||
<div className="cart-quantity-controls">
|
||||
<button onClick={() => updateQuantity(item.id, -1)}>
|
||||
@@ -67,7 +67,7 @@ const CartScreen: React.FC = () => {
|
||||
<h2 className="section-title">Bill Details</h2>
|
||||
<div className="bill-row">
|
||||
<span>Item Total</span>
|
||||
<span>₹{totalPrice.toFixed(2)}</span>
|
||||
<span>R{totalPrice.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="bill-row">
|
||||
<span>Delivery Fee</span>
|
||||
@@ -75,11 +75,11 @@ const CartScreen: React.FC = () => {
|
||||
</div>
|
||||
<div className="bill-row">
|
||||
<span>Taxes and Charges</span>
|
||||
<span>₹2.50</span>
|
||||
<span>R2.50</span>
|
||||
</div>
|
||||
<div className="bill-row total">
|
||||
<span>To Pay</span>
|
||||
<span>₹{(totalPrice + 2.5).toFixed(2)}</span>
|
||||
<span>R{(totalPrice + 2.5).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -87,7 +87,7 @@ const CartScreen: React.FC = () => {
|
||||
<div className="cart-footer">
|
||||
<div className="footer-total">
|
||||
<span className="items-count">{totalItems} {totalItems === 1 ? 'Item' : 'Items'}</span>
|
||||
<span className="final-price">₹{(totalPrice + 2.5).toFixed(2)}</span>
|
||||
<span className="final-price">R{(totalPrice + 2.5).toFixed(2)}</span>
|
||||
</div>
|
||||
<button
|
||||
className="checkout-button"
|
||||
|
||||
@@ -181,7 +181,7 @@ const CheckoutScreen: React.FC = () => {
|
||||
|
||||
<div className="payment-security-note">
|
||||
<ShieldCheck size={14} />
|
||||
<span>Secured by Ritz Token Protocol. 1 Token = ₹1.00</span>
|
||||
<span>Secured by Ritz Token Protocol. 1 Token = R1.00</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ const ItemDetailScreen: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<h1 className="item-name-large">{item.name}</h1>
|
||||
<p className="item-price-large">₹{item.price.toFixed(2)}</p>
|
||||
<p className="item-price-large">R{item.price.toFixed(2)}</p>
|
||||
</div>
|
||||
|
||||
<div className="item-description-section">
|
||||
@@ -88,7 +88,7 @@ const ItemDetailScreen: React.FC = () => {
|
||||
<footer className="item-footer">
|
||||
<div className="footer-price-info">
|
||||
<span className="total-label">Price</span>
|
||||
<span className="total-value">₹{(item.price * Math.max(1, quantity)).toFixed(2)}</span>
|
||||
<span className="total-value">R{(item.price * Math.max(1, quantity)).toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<div className="footer-action">
|
||||
|
||||
@@ -227,7 +227,7 @@ const MyOrdersScreen: React.FC = () => {
|
||||
|
||||
<div className="order-total-bar">
|
||||
<span>Total Amount</span>
|
||||
<span className="amount-text">₹{latestOrder.totalAmount.toFixed(2)}</span>
|
||||
<span className="amount-text">R{latestOrder.totalAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -256,7 +256,7 @@ const MyOrdersScreen: React.FC = () => {
|
||||
<div className="order-list-info">
|
||||
<div className="order-list-top">
|
||||
<span className="order-list-number">#{order.displayOrderId}</span>
|
||||
<span className="order-list-price">₹{order.totalAmount.toFixed(0)}</span>
|
||||
<span className="order-list-price">R{order.totalAmount.toFixed(0)}</span>
|
||||
</div>
|
||||
<div className="order-list-bottom">
|
||||
<span className="order-list-date">{new Date(order.createdAt).toLocaleDateString()}</span>
|
||||
@@ -353,12 +353,12 @@ const MyOrdersScreen: React.FC = () => {
|
||||
{selectedOrder.items.map((item, idx) => (
|
||||
<div key={idx} className="modal-item-row">
|
||||
<span>{item.quantity} x {item.productName}</span>
|
||||
<span>₹{(item.price * item.quantity).toFixed(0)}</span>
|
||||
<span>R{(item.price * item.quantity).toFixed(0)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="modal-total-row">
|
||||
<span>Grand Total</span>
|
||||
<span>₹{selectedOrder.totalAmount.toFixed(2)}</span>
|
||||
<span>R{selectedOrder.totalAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user