deploying

This commit is contained in:
2026-06-13 18:12:53 +03:00
parent 0d98a060e7
commit 4a3db42971
171 changed files with 13003 additions and 810 deletions
+9
View File
@@ -0,0 +1,9 @@
.env
.gradle
build
out
.idea
*.iml
Dockerfile
.dockerignore
+26
View File
@@ -0,0 +1,26 @@
FROM eclipse-temurin:17-jdk-jammy AS build
WORKDIR /workspace
COPY gradlew settings.gradle.kts build.gradle.kts ./
COPY gradle ./gradle
COPY src ./src
RUN chmod +x ./gradlew && ./gradlew --no-daemon bootJar -x test
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
RUN groupadd --system app && useradd --system --gid app app
COPY --from=build /workspace/build/libs/*.jar /app/app.jar
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chmod +x /app/docker-entrypoint.sh
USER app
EXPOSE 8080
ENTRYPOINT ["/app/docker-entrypoint.sh"]
+36
View File
@@ -0,0 +1,36 @@
#!/bin/sh
set -eu
if [ -z "${DATABASE_URL:-}" ] && [ -n "${NEON_DATABASE_URL:-}" ]; then
DATABASE_URL="$NEON_DATABASE_URL"
export DATABASE_URL
fi
case "${DATABASE_URL:-}" in
jdbc:postgresql://*)
export SPRING_DATASOURCE_URL="${SPRING_DATASOURCE_URL:-$DATABASE_URL}"
;;
postgres://*|postgresql://*)
raw_url="${DATABASE_URL#postgres://}"
raw_url="${raw_url#postgresql://}"
if [ "$raw_url" != "${raw_url#*@}" ]; then
credentials="${raw_url%%@*}"
host_and_database="${raw_url#*@}"
if [ -z "${SPRING_DATASOURCE_USERNAME:-}" ]; then
export SPRING_DATASOURCE_USERNAME="${credentials%%:*}"
fi
if [ -z "${SPRING_DATASOURCE_PASSWORD:-}" ] && [ "$credentials" != "${credentials#*:}" ]; then
export SPRING_DATASOURCE_PASSWORD="${credentials#*:}"
fi
else
host_and_database="$raw_url"
fi
export SPRING_DATASOURCE_URL="${SPRING_DATASOURCE_URL:-jdbc:postgresql://$host_and_database}"
;;
esac
exec java ${JAVA_OPTS:-} -jar /app/app.jar
+3 -3
View File
@@ -1,9 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
networkTimeout=60000
retries=3
retryBackOffMs=1000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
@@ -1,5 +1,6 @@
package com.andwati.orders.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
@@ -8,16 +9,24 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class CorsConfig {
@Value("${app.cors.allowed-origins:http://localhost:3000}")
private String allowedOrigins;
@Bean
WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000")
.allowedOrigins(parseAllowedOrigins())
.allowedMethods("GET", "POST", "DELETE", "PUT", "PATCH", "OPTIONS")
.allowedHeaders("*");
.allowedHeaders("*")
.allowCredentials(true);
}
};
}
}
private String[] parseAllowedOrigins() {
return allowedOrigins.split("\\s*,\\s*");
}
}
@@ -2,12 +2,15 @@ package com.andwati.orders.config;
import com.andwati.orders.model.*;
import com.andwati.orders.repository.AppUserRepository;
import com.andwati.orders.repository.InventoryItemRepository;
import com.andwati.orders.repository.OrderRepository;
import com.andwati.orders.repository.ProductRepository;
import com.andwati.orders.repository.RideRepository;
import jakarta.transaction.Transactional;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
@@ -19,6 +22,9 @@ public class DataSeeder implements CommandLineRunner {
private final ProductRepository productRepository;
private final InventoryItemRepository inventoryItemRepository;
private final OrderRepository orderRepository;
private final RideRepository rideRepository;
private final AppUserRepository appUserRepository;
private final PasswordEncoder passwordEncoder;
private final boolean seedData;
@@ -26,97 +32,90 @@ public class DataSeeder implements CommandLineRunner {
ProductRepository productRepository,
InventoryItemRepository inventoryItemRepository,
OrderRepository orderRepository,
RideRepository rideRepository,
AppUserRepository appUserRepository,
PasswordEncoder passwordEncoder,
@Value("${app.seed-data:true}") boolean seedData
) {
this.productRepository = productRepository;
this.inventoryItemRepository = inventoryItemRepository;
this.orderRepository = orderRepository;
this.rideRepository = rideRepository;
this.appUserRepository = appUserRepository;
this.passwordEncoder = passwordEncoder;
this.seedData = seedData;
}
@Override
@Transactional
public void run(String... args) {
if (!seedData || productRepository.count() > 0) {
if (!seedData) {
return;
}
AppUser admin = ensureUser("admin", "Admin User", "admin123", Role.ADMIN);
AppUser driver = ensureUser("driver", "Demo Driver", "driver123", Role.DRIVER);
AppUser driverTwo = ensureUser("driver2", "Amina Driver", "driver123", Role.DRIVER);
AppUser driverThree = ensureUser("driver3", "Otieno Driver", "driver123", Role.DRIVER);
AppUser customer = ensureUser("customer", "Demo Customer", "customer123", Role.CUSTOMER);
AppUser customerTwo = ensureUser("customer2", "Nia Customer", "customer123", Role.CUSTOMER);
AppUser customerThree = ensureUser("customer3", "Baraka Customer", "customer123", Role.CUSTOMER);
AppUser customerFour = ensureUser("customer4", "Wanjiku Customer", "customer123", Role.CUSTOMER);
Product mouse = product("Wireless Mouse", "MOUSE-001", "1500.00");
Product keyboard = product("Keyboard", "KEYBOARD-001", "2500.00");
Product cable = product("USB-C Cable", "USB-C-001", "800.00");
Product stand = product("Laptop Stand", "STAND-001", "3500.00");
Product webcam = product("Webcam", "WEBCAM-001", "4500.00");
Product mouse = ensureProduct("Wireless Mouse", "MOUSE-001", "1500.00", 18, 5);
Product keyboard = ensureProduct("Mechanical Keyboard", "KEYBOARD-001", "4500.00", 9, 4);
Product cable = ensureProduct("USB-C Cable", "USB-C-001", "800.00", 32, 10);
Product stand = ensureProduct("Laptop Stand", "STAND-001", "3500.00", 7, 5);
Product webcam = ensureProduct("Webcam", "WEBCAM-001", "4500.00", 4, 5);
Product headphones = ensureProduct("Noise Cancelling Headphones", "AUDIO-001", "12500.00", 11, 3);
Product monitor = ensureProduct("27 Inch Monitor", "MONITOR-001", "28500.00", 6, 2);
Product dock = ensureProduct("USB-C Docking Station", "DOCK-001", "9200.00", 5, 3);
Product backpack = ensureProduct("Commuter Laptop Backpack", "BAG-001", "6200.00", 15, 6);
Product powerBank = ensureProduct("Fast Charge Power Bank", "POWER-001", "5400.00", 12, 4);
Product tablet = ensureProduct("Drawing Tablet", "TABLET-001", "16900.00", 3, 3);
Product chair = ensureProduct("Ergonomic Office Chair", "CHAIR-001", "24500.00", 2, 2);
productRepository.saveAll(List.of(mouse, keyboard, cable, stand, webcam));
if (orderRepository.count() < 14) {
seedActivity(
List.of(customer, customerTwo, customerThree, customerFour),
List.of(driver, driverTwo, driverThree),
List.of(mouse, keyboard, cable, stand, webcam, headphones, monitor, dock, backpack, powerBank, tablet, chair)
);
}
}
inventoryItemRepository.saveAll(List.of(
inventory(mouse, 12, 5),
inventory(keyboard, 3, 5),
inventory(cable, 0, 5),
inventory(stand, 7, 5),
inventory(webcam, 2, 5)
));
Order placedOrder = new Order();
placedOrder.setCustomerName("Demo Customer");
placedOrder.setStatus(OrderStatus.PLACED);
placedOrder.setCurrency("KES");
placedOrder.setCreatedAt(Instant.now().minusSeconds(3600));
OrderItem itemOne = new OrderItem();
itemOne.setOrder(placedOrder);
itemOne.setProduct(mouse);
itemOne.setProductName(mouse.getName());
itemOne.setQuantity(1);
itemOne.setUnitPrice(mouse.getPrice());
itemOne.setLineTotal(mouse.getPrice());
OrderItem itemTwo = new OrderItem();
itemTwo.setOrder(placedOrder);
itemTwo.setProduct(keyboard);
itemTwo.setProductName(keyboard.getName());
itemTwo.setQuantity(1);
itemTwo.setUnitPrice(keyboard.getPrice());
itemTwo.setLineTotal(keyboard.getPrice());
placedOrder.addItem(itemOne);
placedOrder.addItem(itemTwo);
placedOrder.recalculateTotal();
Order cancelledOrder = new Order();
cancelledOrder.setCustomerName("Cancelled Customer");
cancelledOrder.setStatus(OrderStatus.CANCELLED);
cancelledOrder.setCurrency("KES");
cancelledOrder.setCreatedAt(Instant.now().minusSeconds(86_400));
cancelledOrder.setCancelledAt(Instant.now().minusSeconds(80_000));
OrderItem cancelledItem = new OrderItem();
cancelledItem.setOrder(cancelledOrder);
cancelledItem.setProduct(webcam);
cancelledItem.setProductName(webcam.getName());
cancelledItem.setQuantity(1);
cancelledItem.setUnitPrice(webcam.getPrice());
cancelledItem.setLineTotal(webcam.getPrice());
cancelledOrder.addItem(cancelledItem);
cancelledOrder.recalculateTotal();
orderRepository.saveAll(List.of(placedOrder, cancelledOrder));
private AppUser ensureUser(String username, String displayName, String password, Role role) {
return appUserRepository.findByUsernameIgnoreCase(username)
.orElseGet(() -> appUserRepository.save(new AppUser()
.setUsername(username)
.setDisplayName(displayName)
.setPasswordHash(passwordEncoder.encode(password))
.setRole(role)
.setEnabled(true)));
}
private Product product(String name, String sku, String price) {
Product product = new Product();
product.setName(name);
product.setSku(sku);
product.setPrice(new BigDecimal(price));
product.setCurrency("KES");
product.setActive(true);
product.setCreatedAt(Instant.now());
product.setUpdatedAt(Instant.now());
private Product ensureProduct(String name, String sku, String price, int availableQuantity, int reorderLevel) {
Product product = productRepository.findBySkuIgnoreCase(sku)
.orElseGet(() -> productRepository.save(product(name, sku, price)));
inventoryItemRepository.findByProduct_Id(product.getId())
.orElseGet(() -> inventoryItemRepository.save(inventory(product, availableQuantity, reorderLevel)));
return product;
}
private Product product(String name, String sku, String price) {
return new Product()
.setName(name)
.setSku(sku)
.setPrice(new BigDecimal(price))
.setCurrency("KES")
.setActive(true)
.setCreatedAt(Instant.now())
.setUpdatedAt(Instant.now());
}
private InventoryItem inventory(Product product, int availableQuantity, int reorderLevel) {
InventoryItem inventoryItem = new InventoryItem();
inventoryItem.setProduct(product);
@@ -126,4 +125,102 @@ public class DataSeeder implements CommandLineRunner {
inventoryItem.setUpdatedAt(Instant.now());
return inventoryItem;
}
private void seedActivity(List<AppUser> customers, List<AppUser> drivers, List<Product> products) {
Instant now = Instant.now();
List<Order> orders = List.of(
order(customers.get(0), OrderStatus.PENDING, now.minusSeconds(2_400), line(products.get(0), 1), line(products.get(2), 2)),
order(customers.get(1), OrderStatus.PENDING, now.minusSeconds(7_200), line(products.get(8), 1), line(products.get(9), 1)),
order(customers.get(2), OrderStatus.SHIPPED, now.minusSeconds(26_000), line(products.get(1), 1), line(products.get(3), 1)),
order(customers.get(3), OrderStatus.SHIPPED, now.minusSeconds(44_000), line(products.get(5), 1)),
order(customers.get(0), OrderStatus.DELIVERED, now.minusSeconds(91_000), line(products.get(6), 1), line(products.get(2), 3)),
order(customers.get(1), OrderStatus.DELIVERED, now.minusSeconds(130_000), line(products.get(7), 1), line(products.get(0), 1)),
order(customers.get(2), OrderStatus.DELIVERED, now.minusSeconds(185_000), line(products.get(10), 1), line(products.get(2), 1)),
order(customers.get(3), OrderStatus.DELIVERED, now.minusSeconds(252_000), line(products.get(11), 1)),
order(customers.get(0), OrderStatus.CANCELLED, now.minusSeconds(316_000), line(products.get(4), 1)),
order(customers.get(1), OrderStatus.PENDING, now.minusSeconds(374_000), line(products.get(3), 1), line(products.get(9), 2)),
order(customers.get(2), OrderStatus.SHIPPED, now.minusSeconds(442_000), line(products.get(5), 1), line(products.get(8), 1)),
order(customers.get(3), OrderStatus.DELIVERED, now.minusSeconds(518_000), line(products.get(6), 1), line(products.get(1), 1))
);
orderRepository.saveAll(orders);
rideRepository.saveAll(List.of(
ride(orders.get(0), null, RideStatus.REQUESTED, "Westlands warehouse", "Kilimani, Nairobi", "7.20", null, now.minusSeconds(2_100)),
ride(orders.get(1), null, RideStatus.REQUESTED, "Mombasa Road hub", "Syokimau", "11.50", null, now.minusSeconds(6_800)),
ride(orders.get(2), drivers.get(0), RideStatus.ACCEPTED, "CBD pickup", "Lavington", "6.30", "950.00", now.minusSeconds(24_000)),
ride(orders.get(3), drivers.get(1), RideStatus.ACCEPTED, "Industrial Area", "Kileleshwa", "8.40", "1180.00", now.minusSeconds(42_000)),
ride(orders.get(4), drivers.get(0), RideStatus.COMPLETED, "Thika Road hub", "Roysambu", "13.10", "1580.00", now.minusSeconds(88_000)),
ride(orders.get(5), drivers.get(2), RideStatus.COMPLETED, "Karen store", "Langata", "5.80", "870.00", now.minusSeconds(128_000)),
ride(orders.get(6), drivers.get(1), RideStatus.COMPLETED, "Westlands warehouse", "Parklands", "4.60", "760.00", now.minusSeconds(182_000)),
ride(orders.get(8), null, RideStatus.CANCELLED, "CBD pickup", "Upper Hill", "3.90", null, now.minusSeconds(314_000)),
ride(orders.get(10), drivers.get(2), RideStatus.ACCEPTED, "Mombasa Road hub", "Athi River", "18.50", "2140.00", now.minusSeconds(438_000)),
ride(orders.get(11), drivers.get(0), RideStatus.COMPLETED, "Industrial Area", "Embakasi", "9.20", "1240.00", now.minusSeconds(514_000))
));
}
private Order order(AppUser customer, OrderStatus status, Instant createdAt, SeedLine... lines) {
Order order = new Order()
.setCustomer(customer)
.setCustomerName(customer.getDisplayName())
.setStatus(status)
.setCurrency("KES")
.setCreatedAt(createdAt);
if (status == OrderStatus.CANCELLED) {
order.setCancelledAt(createdAt.plusSeconds(900));
}
for (SeedLine line : lines) {
order.addItem(OrderItem.from(line.product(), line.quantity()));
}
order.recalculateTotal();
return order;
}
private Ride ride(
Order order,
AppUser driver,
RideStatus status,
String pickup,
String dropoff,
String distanceKm,
String fareAmount,
Instant requestedAt
) {
Ride ride = new Ride()
.setOrder(order)
.setCustomer(order.getCustomer())
.setDriver(driver)
.setPickupLocation(pickup)
.setDropoffLocation(dropoff)
.setDistanceKm(new BigDecimal(distanceKm))
.setStatus(status)
.setRequestedAt(requestedAt);
if (fareAmount != null) {
ride.setFareAmount(new BigDecimal(fareAmount)).setCurrency("KES");
}
if (status == RideStatus.ACCEPTED || status == RideStatus.COMPLETED) {
ride.setAcceptedAt(requestedAt.plusSeconds(1_200));
}
if (status == RideStatus.COMPLETED) {
ride.setCompletedAt(requestedAt.plusSeconds(7_200));
}
if (status == RideStatus.CANCELLED) {
ride.setCancelledAt(requestedAt.plusSeconds(1_800));
}
return ride;
}
private SeedLine line(Product product, int quantity) {
return new SeedLine(product, quantity);
}
private record SeedLine(Product product, int quantity) {
}
}
@@ -0,0 +1,28 @@
package com.andwati.orders.config;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class OrderStatusMigrator implements CommandLineRunner {
private final JdbcTemplate jdbcTemplate;
public OrderStatusMigrator(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public void run(String... args) {
try {
jdbcTemplate.update("alter table orders drop constraint if exists orders_status_check");
jdbcTemplate.update("update orders set status = 'PENDING' where status = 'PLACED'");
} catch (RuntimeException ignored) {
// The table may not exist yet during first boot with hibernate ddl-auto=update.
}
}
}
@@ -0,0 +1,72 @@
package com.andwati.orders.config;
import com.andwati.orders.services.AppUserDetailsService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
CsrfTokenRequestAttributeHandler csrfRequestHandler = new CsrfTokenRequestAttributeHandler();
csrfRequestHandler.setCsrfRequestAttributeName(null);
return http
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(csrfRequestHandler)
)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/health", "/api/auth/csrf").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/login", "/api/auth/register").permitAll()
.requestMatchers("/api-docs/**", "/swagger-ui.html", "/swagger-ui/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/products/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.PUT, "/api/products/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.PATCH, "/api/products/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
.requestMatchers("/api/inventory/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.build();
}
@Bean
DaoAuthenticationProvider authenticationProvider(
AppUserDetailsService appUserDetailsService,
PasswordEncoder passwordEncoder
) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(appUserDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return provider;
}
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@@ -0,0 +1,79 @@
package com.andwati.orders.controller;
import com.andwati.orders.dto.request.LoginRequest;
import com.andwati.orders.dto.request.RegisterRequest;
import com.andwati.orders.dto.response.AuthUserResponse;
import com.andwati.orders.dto.response.CsrfResponse;
import com.andwati.orders.services.AppUserService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final AppUserService appUserService;
public AuthController(AuthenticationManager authenticationManager, AppUserService appUserService) {
this.authenticationManager = authenticationManager;
this.appUserService = appUserService;
}
@PostMapping("/register")
public AuthUserResponse register(@Valid @RequestBody RegisterRequest request) {
return appUserService.registerCustomer(request);
}
@PostMapping("/login")
public AuthUserResponse login(@Valid @RequestBody LoginRequest request, HttpServletRequest servletRequest) {
var authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
appUserService.normalizeUsername(request.username()),
request.password()
)
);
var context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
servletRequest.getSession(true).setAttribute(
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
context
);
return me();
}
@PostMapping("/logout")
public void logout(HttpServletRequest request) {
SecurityContextHolder.clearContext();
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
}
@GetMapping("/me")
public AuthUserResponse me() {
return appUserService.toResponse(appUserService.getCurrentUser());
}
@GetMapping("/csrf")
public CsrfResponse csrf(CsrfToken csrfToken) {
return new CsrfResponse(
csrfToken.getHeaderName(),
csrfToken.getParameterName(),
csrfToken.getToken()
);
}
}
@@ -1,6 +1,7 @@
package com.andwati.orders.controller;
import com.andwati.orders.dto.request.CreateOrderRequest;
import com.andwati.orders.dto.request.UpdateOrderStatusRequest;
import com.andwati.orders.dto.response.CancelOrderResponse;
import com.andwati.orders.dto.response.OrderResponse;
import com.andwati.orders.dto.response.OrderSummaryResponse;
@@ -51,7 +52,7 @@ public class OrderController {
)
@GetMapping
public PageResponse<OrderSummaryResponse> listOrders(
@Parameter(description = "Optional order status filter.", example = "PLACED")
@Parameter(description = "Optional order status filter.", example = "PENDING")
@RequestParam(required = false) OrderStatus status,
@Parameter(
@@ -107,4 +108,13 @@ public class OrderController {
) {
return orderService.cancelOrder(id);
}
@PatchMapping("/{id}/status")
public OrderResponse updateStatus(
@Parameter(description = "Order ID.", example = "11111111-1111-1111-1111-111111111111")
@PathVariable UUID id,
@Valid @RequestBody UpdateOrderStatusRequest request
) {
return orderService.updateStatus(id, request.status());
}
}
@@ -0,0 +1,48 @@
package com.andwati.orders.controller;
import com.andwati.orders.dto.request.ProductRequest;
import com.andwati.orders.dto.response.ProductResponse;
import com.andwati.orders.services.ProductService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping
public List<ProductResponse> listProducts() {
return productService.listProducts();
}
@GetMapping("/{id}")
public ProductResponse getProduct(@PathVariable UUID id) {
return productService.getProduct(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ProductResponse createProduct(@Valid @RequestBody ProductRequest request) {
return productService.createProduct(request);
}
@PutMapping("/{id}")
public ProductResponse updateProduct(@PathVariable UUID id, @Valid @RequestBody ProductRequest request) {
return productService.updateProduct(id, request);
}
@DeleteMapping("/{id}")
public ProductResponse deactivateProduct(@PathVariable UUID id) {
return productService.deactivateProduct(id);
}
}
@@ -0,0 +1,57 @@
package com.andwati.orders.controller;
import com.andwati.orders.dto.request.CreateRideRequest;
import com.andwati.orders.dto.request.UpdateRideStatusRequest;
import com.andwati.orders.dto.response.RideResponse;
import com.andwati.orders.services.RideService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/rides")
public class RideController {
private final RideService rideService;
public RideController(RideService rideService) {
this.rideService = rideService;
}
@GetMapping
public List<RideResponse> listRides() {
return rideService.listRides();
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public RideResponse requestRide(@Valid @RequestBody CreateRideRequest request) {
return rideService.requestRide(request);
}
@PostMapping("/{id}/accept")
public RideResponse acceptRide(@PathVariable UUID id) {
return rideService.acceptRide(id);
}
@PostMapping("/{id}/complete")
public RideResponse completeRide(@PathVariable UUID id) {
return rideService.completeRide(id);
}
@DeleteMapping("/{id}")
public RideResponse cancelRide(@PathVariable UUID id) {
return rideService.cancelRide(id);
}
@PatchMapping("/{id}/status")
public RideResponse updateStatus(
@PathVariable UUID id,
@Valid @RequestBody UpdateRideStatusRequest request
) {
return rideService.updateStatus(id, request.status());
}
}
@@ -0,0 +1,68 @@
package com.andwati.orders.controller;
import com.andwati.orders.dto.request.AdminUserRequest;
import com.andwati.orders.dto.response.UserResponse;
import com.andwati.orders.services.AppUserService;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final AppUserService appUserService;
public UserController(AppUserService appUserService) {
this.appUserService = appUserService;
}
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public List<UserResponse> listUsers() {
return appUserService.listUsers();
}
@GetMapping("/customers")
@PreAuthorize("hasRole('ADMIN')")
public List<UserResponse> listCustomers() {
return appUserService.listCustomers();
}
@GetMapping("/drivers")
@PreAuthorize("hasRole('ADMIN')")
public List<UserResponse> listDrivers() {
return appUserService.listDrivers();
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@PreAuthorize("hasRole('ADMIN')")
public UserResponse createUser(@Valid @RequestBody AdminUserRequest request) {
return appUserService.createUser(request);
}
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public UserResponse updateUser(@PathVariable UUID id, @Valid @RequestBody AdminUserRequest request) {
return appUserService.updateUser(id, request);
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public UserResponse disableUser(@PathVariable UUID id) {
return appUserService.disableUser(id);
}
}
@@ -0,0 +1,25 @@
package com.andwati.orders.dto.request;
import com.andwati.orders.model.Role;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public record AdminUserRequest(
@NotBlank(message = "username is required")
@Size(max = 80, message = "username must be 80 characters or fewer")
String username,
@NotBlank(message = "displayName is required")
@Size(max = 120, message = "displayName must be 120 characters or fewer")
String displayName,
@Size(min = 6, max = 120, message = "password must be between 6 and 120 characters")
String password,
@NotNull(message = "role is required")
Role role,
boolean enabled
) {
}
@@ -1,18 +1,16 @@
package com.andwati.orders.dto.request;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.List;
public record CreateOrderRequest(
@NotBlank(message = "customerName is required")
@Size(min = 2, max = 120, message = "customerName must be between 2 and 120 characters")
String customerName,
@Valid
@Size(min = 1, message = "items must contain at least one item")
List<CreateOrderItemRequest> items
List<CreateOrderItemRequest> items,
@Valid
CreateOrderRideRequest deliveryRide
) {
}
@@ -0,0 +1,23 @@
package com.andwati.orders.dto.request;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
public record CreateOrderRideRequest(
@NotBlank(message = "pickupLocation is required")
@Size(max = 160, message = "pickupLocation must be 160 characters or fewer")
String pickupLocation,
@NotBlank(message = "dropoffLocation is required")
@Size(max = 160, message = "dropoffLocation must be 160 characters or fewer")
String dropoffLocation,
@NotNull(message = "distanceKm is required")
@DecimalMin(value = "0.1", message = "distanceKm must be greater than 0")
BigDecimal distanceKm
) {
}
@@ -0,0 +1,27 @@
package com.andwati.orders.dto.request;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.util.UUID;
public record CreateRideRequest(
@NotNull(message = "orderId is required")
UUID orderId,
@NotBlank(message = "pickupLocation is required")
@Size(max = 160, message = "pickupLocation must be 160 characters or fewer")
String pickupLocation,
@NotBlank(message = "dropoffLocation is required")
@Size(max = 160, message = "dropoffLocation must be 160 characters or fewer")
String dropoffLocation,
@NotNull(message = "distanceKm is required")
@DecimalMin(value = "0.1", message = "distanceKm must be greater than 0")
BigDecimal distanceKm
) {
}
@@ -0,0 +1,12 @@
package com.andwati.orders.dto.request;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(
@NotBlank(message = "username is required")
String username,
@NotBlank(message = "password is required")
String password
) {
}
@@ -0,0 +1,36 @@
package com.andwati.orders.dto.request;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
public record ProductRequest(
@NotBlank(message = "name is required")
@Size(max = 120, message = "name must be 120 characters or fewer")
String name,
@NotBlank(message = "sku is required")
@Size(max = 80, message = "sku must be 80 characters or fewer")
String sku,
@NotNull(message = "price is required")
@DecimalMin(value = "0.01", message = "price must be greater than 0")
BigDecimal price,
@NotBlank(message = "currency is required")
@Size(min = 3, max = 3, message = "currency must be a 3-letter code")
String currency,
boolean active,
@Min(value = 0, message = "availableQuantity must be greater than or equal to 0")
int availableQuantity,
@Min(value = 0, message = "reorderLevel must be greater than or equal to 0")
int reorderLevel
) {
}
@@ -0,0 +1,19 @@
package com.andwati.orders.dto.request;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record RegisterRequest(
@NotBlank(message = "username is required")
@Size(min = 3, max = 80, message = "username must be between 3 and 80 characters")
String username,
@NotBlank(message = "displayName is required")
@Size(min = 2, max = 120, message = "displayName must be between 2 and 120 characters")
String displayName,
@NotBlank(message = "password is required")
@Size(min = 6, max = 120, message = "password must be between 6 and 120 characters")
String password
) {
}
@@ -0,0 +1,10 @@
package com.andwati.orders.dto.request;
import com.andwati.orders.model.OrderStatus;
import jakarta.validation.constraints.NotNull;
public record UpdateOrderStatusRequest(
@NotNull(message = "status is required")
OrderStatus status
) {
}
@@ -0,0 +1,10 @@
package com.andwati.orders.dto.request;
import com.andwati.orders.model.RideStatus;
import jakarta.validation.constraints.NotNull;
public record UpdateRideStatusRequest(
@NotNull(message = "status is required")
RideStatus status
) {
}
@@ -0,0 +1,13 @@
package com.andwati.orders.dto.response;
import com.andwati.orders.model.Role;
import java.util.UUID;
public record AuthUserResponse(
UUID id,
String username,
String displayName,
Role role
) {
}
@@ -0,0 +1,8 @@
package com.andwati.orders.dto.response;
public record CsrfResponse(
String headerName,
String parameterName,
String token
) {
}
@@ -9,6 +9,7 @@ import java.util.UUID;
public record OrderResponse(
UUID id,
UUID customerId,
String customerName,
OrderStatus status,
List<OrderItemResponse> items,
@@ -17,4 +18,4 @@ public record OrderResponse(
Instant createdAt,
Instant cancelledAt
) {
}
}
@@ -8,6 +8,7 @@ import java.util.UUID;
public record OrderSummaryResponse(
UUID id,
UUID customerId,
String customerName,
OrderStatus status,
BigDecimal totalAmount,
@@ -16,4 +17,4 @@ public record OrderSummaryResponse(
Instant createdAt,
Instant cancelledAt
) {
}
}
@@ -0,0 +1,18 @@
package com.andwati.orders.dto.response;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
public record ProductResponse(
UUID id,
String name,
String sku,
BigDecimal price,
String currency,
boolean active,
int availableQuantity,
int reorderLevel,
Instant updatedAt
) {
}
@@ -0,0 +1,27 @@
package com.andwati.orders.dto.response;
import com.andwati.orders.model.RideStatus;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
public record RideResponse(
UUID id,
UUID orderId,
UUID customerId,
String customerName,
UUID driverId,
String driverName,
String pickupLocation,
String dropoffLocation,
BigDecimal distanceKm,
BigDecimal fareAmount,
String currency,
RideStatus status,
Instant requestedAt,
Instant acceptedAt,
Instant completedAt,
Instant cancelledAt
) {
}
@@ -0,0 +1,16 @@
package com.andwati.orders.dto.response;
import com.andwati.orders.model.Role;
import java.time.Instant;
import java.util.UUID;
public record UserResponse(
UUID id,
String username,
String displayName,
Role role,
boolean enabled,
Instant createdAt
) {
}
@@ -5,6 +5,8 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
@@ -159,6 +161,34 @@ public class GlobalExceptionHandler {
);
}
@ExceptionHandler(BadCredentialsException.class)
public ResponseEntity<ErrorResponse> handleBadCredentials(
BadCredentialsException exception,
HttpServletRequest request
) {
return build(
HttpStatus.UNAUTHORIZED,
"AUTHENTICATION_FAILED",
"Invalid username or password",
null,
request
);
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDenied(
AccessDeniedException exception,
HttpServletRequest request
) {
return build(
HttpStatus.FORBIDDEN,
"ACCESS_DENIED",
"You do not have permission to perform this action",
null,
request
);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(
Exception exception,
@@ -197,4 +227,4 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(status).body(response);
}
}
}
@@ -5,6 +5,6 @@ import java.util.UUID;
public class OrderNotCancellableException extends RuntimeException {
public OrderNotCancellableException(UUID orderId) {
super("Order " + orderId + " cannot be cancelled because it is not in PLACED status");
super("Order " + orderId + " cannot be cancelled because it is not in PENDING status");
}
}
}
@@ -14,6 +14,7 @@ public class OrderMapper {
public OrderResponse toResponse(Order order) {
return new OrderResponse(
order.getId(),
order.getCustomer() == null ? null : order.getCustomer().getId(),
order.getCustomerName(),
order.getStatus(),
order.getItems().stream().map(this::toItemResponse).toList(),
@@ -32,6 +33,7 @@ public class OrderMapper {
return new OrderSummaryResponse(
order.getId(),
order.getCustomer() == null ? null : order.getCustomer().getId(),
order.getCustomerName(),
order.getStatus(),
order.getTotalAmount(),
@@ -60,4 +62,4 @@ public class OrderMapper {
item.getLineTotal()
);
}
}
}
@@ -0,0 +1,24 @@
package com.andwati.orders.mappers;
import com.andwati.orders.dto.response.ProductResponse;
import com.andwati.orders.model.InventoryItem;
import com.andwati.orders.model.Product;
import org.springframework.stereotype.Component;
@Component
public class ProductMapper {
public ProductResponse toResponse(Product product, InventoryItem inventoryItem) {
return new ProductResponse(
product.getId(),
product.getName(),
product.getSku(),
product.getPrice(),
product.getCurrency(),
product.isActive(),
inventoryItem == null ? 0 : inventoryItem.getAvailableQuantity(),
inventoryItem == null ? 0 : inventoryItem.getReorderLevel(),
inventoryItem == null ? product.getUpdatedAt() : inventoryItem.getUpdatedAt()
);
}
}
@@ -0,0 +1,30 @@
package com.andwati.orders.mappers;
import com.andwati.orders.dto.response.RideResponse;
import com.andwati.orders.model.Ride;
import org.springframework.stereotype.Component;
@Component
public class RideMapper {
public RideResponse toResponse(Ride ride) {
return new RideResponse(
ride.getId(),
ride.getOrder() == null ? null : ride.getOrder().getId(),
ride.getCustomer().getId(),
ride.getCustomer().getDisplayName(),
ride.getDriver() == null ? null : ride.getDriver().getId(),
ride.getDriver() == null ? null : ride.getDriver().getDisplayName(),
ride.getPickupLocation(),
ride.getDropoffLocation(),
ride.getDistanceKm(),
ride.getFareAmount(),
ride.getCurrency(),
ride.getStatus(),
ride.getRequestedAt(),
ride.getAcceptedAt(),
ride.getCompletedAt(),
ride.getCancelledAt()
);
}
}
@@ -0,0 +1,132 @@
package com.andwati.orders.model;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(
name = "app_users",
uniqueConstraints = {
@UniqueConstraint(name = "uk_app_users_username", columnNames = "username")
}
)
public class AppUser {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false, length = 80)
private String username;
@Column(nullable = false, length = 120)
private String displayName;
@Column(nullable = false)
private String passwordHash;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 30)
private Role role;
@Column(nullable = false)
private boolean enabled = true;
@Column(nullable = false)
private Instant createdAt;
@Column(nullable = false)
private Instant updatedAt;
@PrePersist
public void prePersist() {
Instant now = Instant.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
}
@PreUpdate
public void preUpdate() {
updatedAt = Instant.now();
}
public UUID getId() {
return id;
}
public AppUser setId(UUID id) {
this.id = id;
return this;
}
public String getUsername() {
return username;
}
public AppUser setUsername(String username) {
this.username = username;
return this;
}
public String getDisplayName() {
return displayName;
}
public AppUser setDisplayName(String displayName) {
this.displayName = displayName;
return this;
}
public String getPasswordHash() {
return passwordHash;
}
public AppUser setPasswordHash(String passwordHash) {
this.passwordHash = passwordHash;
return this;
}
public Role getRole() {
return role;
}
public AppUser setRole(Role role) {
this.role = role;
return this;
}
public boolean isEnabled() {
return enabled;
}
public AppUser setEnabled(boolean enabled) {
this.enabled = enabled;
return this;
}
public Instant getCreatedAt() {
return createdAt;
}
public AppUser setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
return this;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public AppUser setUpdatedAt(Instant updatedAt) {
this.updatedAt = updatedAt;
return this;
}
}
@@ -20,9 +20,13 @@ public class Order {
@Column(nullable = false, length = 120)
private String customerName;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private AppUser customer;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 30)
private OrderStatus status = OrderStatus.PLACED;
private OrderStatus status = OrderStatus.PENDING;
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal totalAmount = BigDecimal.ZERO;
@@ -53,7 +57,7 @@ public class Order {
}
if (status == null) {
status = OrderStatus.PLACED;
status = OrderStatus.PENDING;
}
}
@@ -70,14 +74,59 @@ public class Order {
}
public boolean isCancellable() {
return status == OrderStatus.PLACED;
return status == OrderStatus.PENDING;
}
public void cancel() {
if (!isCancellable()) {
throw new IllegalStateException("Only pending orders can be cancelled");
}
this.status = OrderStatus.CANCELLED;
this.cancelledAt = Instant.now();
}
public void markDeliveredFromRide() {
if (status == OrderStatus.CANCELLED) {
throw new IllegalArgumentException("Cancelled orders cannot be delivered");
}
if (status == OrderStatus.DELIVERED) {
return;
}
status = OrderStatus.DELIVERED;
}
public void updateStatus(OrderStatus nextStatus) {
if (nextStatus == null) {
throw new IllegalArgumentException("status is required");
}
if (status == OrderStatus.CANCELLED || status == OrderStatus.DELIVERED) {
throw new IllegalArgumentException("Final orders cannot be updated");
}
if (nextStatus == OrderStatus.PENDING) {
throw new IllegalArgumentException("Orders cannot be moved back to pending");
}
if (status == OrderStatus.PENDING && (nextStatus == OrderStatus.SHIPPED || nextStatus == OrderStatus.CANCELLED)) {
status = nextStatus;
if (nextStatus == OrderStatus.CANCELLED) {
cancelledAt = Instant.now();
}
return;
}
if (status == OrderStatus.SHIPPED && nextStatus == OrderStatus.DELIVERED) {
status = nextStatus;
return;
}
throw new IllegalArgumentException("Invalid order status transition");
}
public UUID getId() {
return id;
}
@@ -96,6 +145,15 @@ public class Order {
return this;
}
public AppUser getCustomer() {
return customer;
}
public Order setCustomer(AppUser customer) {
this.customer = customer;
return this;
}
public OrderStatus getStatus() {
return status;
}
@@ -2,6 +2,8 @@ package com.andwati.orders.model;
public enum OrderStatus {
PLACED,
PENDING,
SHIPPED,
DELIVERED,
CANCELLED
}
}
@@ -0,0 +1,227 @@
package com.andwati.orders.model;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "rides")
public class Ride {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_id", nullable = false)
private AppUser customer;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "driver_id")
private AppUser driver;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
@Column(nullable = false, length = 160)
private String pickupLocation;
@Column(nullable = false, length = 160)
private String dropoffLocation;
@Column(nullable = false, precision = 8, scale = 2)
private BigDecimal distanceKm;
@Column(precision = 12, scale = 2)
private BigDecimal fareAmount;
@Column(nullable = false, length = 3)
private String currency = "KES";
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 30)
private RideStatus status = RideStatus.REQUESTED;
@Column(nullable = false)
private Instant requestedAt;
private Instant acceptedAt;
private Instant completedAt;
private Instant cancelledAt;
@PrePersist
public void prePersist() {
if (requestedAt == null) {
requestedAt = Instant.now();
}
if (status == null) {
status = RideStatus.REQUESTED;
}
}
public void accept(AppUser driver, BigDecimal fareAmount, String currency) {
if (status != RideStatus.REQUESTED) {
throw new IllegalArgumentException("Only requested rides can be accepted");
}
this.driver = driver;
this.fareAmount = fareAmount;
this.currency = currency;
this.status = RideStatus.ACCEPTED;
this.acceptedAt = Instant.now();
}
public void complete(AppUser driver) {
if (status != RideStatus.ACCEPTED) {
throw new IllegalArgumentException("Only accepted rides can be completed");
}
if (this.driver == null || !this.driver.getId().equals(driver.getId())) {
throw new IllegalArgumentException("Only the assigned driver can complete this ride");
}
this.status = RideStatus.COMPLETED;
this.completedAt = Instant.now();
}
public void cancel() {
if (status == RideStatus.COMPLETED) {
throw new IllegalArgumentException("Completed rides cannot be cancelled");
}
this.status = RideStatus.CANCELLED;
this.cancelledAt = Instant.now();
}
public UUID getId() {
return id;
}
public Ride setId(UUID id) {
this.id = id;
return this;
}
public AppUser getCustomer() {
return customer;
}
public Ride setCustomer(AppUser customer) {
this.customer = customer;
return this;
}
public AppUser getDriver() {
return driver;
}
public Ride setDriver(AppUser driver) {
this.driver = driver;
return this;
}
public Order getOrder() {
return order;
}
public Ride setOrder(Order order) {
this.order = order;
return this;
}
public String getPickupLocation() {
return pickupLocation;
}
public Ride setPickupLocation(String pickupLocation) {
this.pickupLocation = pickupLocation;
return this;
}
public String getDropoffLocation() {
return dropoffLocation;
}
public Ride setDropoffLocation(String dropoffLocation) {
this.dropoffLocation = dropoffLocation;
return this;
}
public BigDecimal getDistanceKm() {
return distanceKm;
}
public Ride setDistanceKm(BigDecimal distanceKm) {
this.distanceKm = distanceKm;
return this;
}
public BigDecimal getFareAmount() {
return fareAmount;
}
public Ride setFareAmount(BigDecimal fareAmount) {
this.fareAmount = fareAmount;
return this;
}
public String getCurrency() {
return currency;
}
public Ride setCurrency(String currency) {
this.currency = currency;
return this;
}
public RideStatus getStatus() {
return status;
}
public Ride setStatus(RideStatus status) {
this.status = status;
return this;
}
public Instant getRequestedAt() {
return requestedAt;
}
public Ride setRequestedAt(Instant requestedAt) {
this.requestedAt = requestedAt;
return this;
}
public Instant getAcceptedAt() {
return acceptedAt;
}
public Ride setAcceptedAt(Instant acceptedAt) {
this.acceptedAt = acceptedAt;
return this;
}
public Instant getCompletedAt() {
return completedAt;
}
public Ride setCompletedAt(Instant completedAt) {
this.completedAt = completedAt;
return this;
}
public Instant getCancelledAt() {
return cancelledAt;
}
public Ride setCancelledAt(Instant cancelledAt) {
this.cancelledAt = cancelledAt;
return this;
}
}
@@ -0,0 +1,8 @@
package com.andwati.orders.model;
public enum RideStatus {
REQUESTED,
ACCEPTED,
COMPLETED,
CANCELLED
}
@@ -0,0 +1,7 @@
package com.andwati.orders.model;
public enum Role {
ADMIN,
CUSTOMER,
DRIVER
}
@@ -0,0 +1,22 @@
package com.andwati.orders.repository;
import com.andwati.orders.model.AppUser;
import com.andwati.orders.model.Role;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface AppUserRepository extends JpaRepository<AppUser, UUID> {
Optional<AppUser> findByUsernameIgnoreCase(String username);
boolean existsByUsernameIgnoreCase(String username);
boolean existsByRole(Role role);
List<AppUser> findByRoleOrderByCreatedAtDesc(Role role);
List<AppUser> findAllByOrderByCreatedAtDesc();
}
@@ -10,6 +10,6 @@ import java.util.UUID;
public interface OrderRepository extends JpaRepository<Order, UUID>, JpaSpecificationExecutor<Order> {
@EntityGraph(attributePaths = {"items", "items.product"})
@EntityGraph(attributePaths = {"items", "items.product", "customer"})
Optional<Order> findWithItemsById(UUID id);
}
}
@@ -5,9 +5,12 @@ import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface ProductRepository extends JpaRepository<Product, UUID> {
List<Product> findByIdIn(Collection<UUID> ids);
}
Optional<Product> findBySkuIgnoreCase(String sku);
}
@@ -0,0 +1,30 @@
package com.andwati.orders.repository;
import com.andwati.orders.model.Ride;
import com.andwati.orders.model.RideStatus;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface RideRepository extends JpaRepository<Ride, UUID> {
@EntityGraph(attributePaths = {"customer", "driver", "order"})
List<Ride> findByCustomer_IdOrderByRequestedAtDesc(UUID customerId);
@EntityGraph(attributePaths = {"customer", "driver", "order"})
List<Ride> findByDriver_IdOrderByRequestedAtDesc(UUID driverId);
@EntityGraph(attributePaths = {"customer", "driver", "order"})
List<Ride> findByStatusOrderByRequestedAtAsc(RideStatus status);
@EntityGraph(attributePaths = {"customer", "driver", "order"})
List<Ride> findAllByOrderByRequestedAtDesc();
@EntityGraph(attributePaths = {"customer", "driver", "order"})
Optional<Ride> findWithUsersById(UUID id);
boolean existsByOrder_Id(UUID orderId);
}
@@ -0,0 +1,37 @@
package com.andwati.orders.services;
import com.andwati.orders.repository.AppUserRepository;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class AppUserDetailsService implements UserDetailsService {
private final AppUserRepository appUserRepository;
public AppUserDetailsService(AppUserRepository appUserRepository) {
this.appUserRepository = appUserRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
var user = appUserRepository.findByUsernameIgnoreCase(username.trim().toLowerCase())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return new User(
user.getUsername(),
user.getPasswordHash(),
user.isEnabled(),
true,
true,
true,
List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name()))
);
}
}
@@ -0,0 +1,161 @@
package com.andwati.orders.services;
import com.andwati.orders.dto.request.AdminUserRequest;
import com.andwati.orders.dto.request.RegisterRequest;
import com.andwati.orders.dto.response.AuthUserResponse;
import com.andwati.orders.dto.response.UserResponse;
import com.andwati.orders.model.AppUser;
import com.andwati.orders.model.Role;
import com.andwati.orders.repository.AppUserRepository;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
@Service
public class AppUserService {
private final AppUserRepository appUserRepository;
private final PasswordEncoder passwordEncoder;
public AppUserService(AppUserRepository appUserRepository, PasswordEncoder passwordEncoder) {
this.appUserRepository = appUserRepository;
this.passwordEncoder = passwordEncoder;
}
@Transactional
public AuthUserResponse registerCustomer(RegisterRequest request) {
String username = normalizeUsername(request.username());
if (appUserRepository.existsByUsernameIgnoreCase(username)) {
throw new IllegalArgumentException("Username is already taken");
}
AppUser user = new AppUser()
.setUsername(username)
.setDisplayName(request.displayName().trim())
.setPasswordHash(passwordEncoder.encode(request.password()))
.setRole(Role.CUSTOMER)
.setEnabled(true);
return toResponse(appUserRepository.save(user));
}
@Transactional(readOnly = true)
public AppUser getCurrentUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
throw new IllegalArgumentException("Authentication is required");
}
return appUserRepository.findByUsernameIgnoreCase(authentication.getName())
.orElseThrow(() -> new IllegalArgumentException("Authenticated user was not found"));
}
public AuthUserResponse toResponse(AppUser user) {
return new AuthUserResponse(
user.getId(),
user.getUsername(),
user.getDisplayName(),
user.getRole()
);
}
@Transactional(readOnly = true)
public List<UserResponse> listUsers() {
return appUserRepository.findAllByOrderByCreatedAtDesc()
.stream()
.map(this::toUserResponse)
.toList();
}
@Transactional(readOnly = true)
public List<UserResponse> listCustomers() {
return appUserRepository.findByRoleOrderByCreatedAtDesc(Role.CUSTOMER)
.stream()
.map(this::toUserResponse)
.toList();
}
@Transactional(readOnly = true)
public List<UserResponse> listDrivers() {
return appUserRepository.findByRoleOrderByCreatedAtDesc(Role.DRIVER)
.stream()
.map(this::toUserResponse)
.toList();
}
@Transactional
public UserResponse createUser(AdminUserRequest request) {
String username = normalizeUsername(request.username());
if (appUserRepository.existsByUsernameIgnoreCase(username)) {
throw new IllegalArgumentException("Username is already taken");
}
if (request.password() == null || request.password().isBlank()) {
throw new IllegalArgumentException("password is required");
}
AppUser user = new AppUser()
.setUsername(username)
.setDisplayName(request.displayName().trim())
.setPasswordHash(passwordEncoder.encode(request.password()))
.setRole(request.role())
.setEnabled(request.enabled());
return toUserResponse(appUserRepository.save(user));
}
@Transactional
public UserResponse updateUser(UUID id, AdminUserRequest request) {
AppUser user = appUserRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("User was not found"));
String username = normalizeUsername(request.username());
appUserRepository.findByUsernameIgnoreCase(username)
.filter(existing -> !existing.getId().equals(id))
.ifPresent(existing -> {
throw new IllegalArgumentException("Username is already taken");
});
user.setUsername(username)
.setDisplayName(request.displayName().trim())
.setRole(request.role())
.setEnabled(request.enabled());
if (request.password() != null && !request.password().isBlank()) {
user.setPasswordHash(passwordEncoder.encode(request.password()));
}
return toUserResponse(user);
}
@Transactional
public UserResponse disableUser(UUID id) {
AppUser user = appUserRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("User was not found"));
user.setEnabled(false);
return toUserResponse(user);
}
private UserResponse toUserResponse(AppUser user) {
return new UserResponse(
user.getId(),
user.getUsername(),
user.getDisplayName(),
user.getRole(),
user.isEnabled(),
user.getCreatedAt()
);
}
public String normalizeUsername(String username) {
return username.trim().toLowerCase();
}
}
@@ -11,6 +11,7 @@ import com.andwati.orders.model.*;
import com.andwati.orders.repository.InventoryItemRepository;
import com.andwati.orders.repository.OrderRepository;
import com.andwati.orders.repository.ProductRepository;
import com.andwati.orders.repository.RideRepository;
import jakarta.persistence.criteria.Predicate;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
@@ -33,22 +34,33 @@ public class OrderService {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
private final InventoryItemRepository inventoryItemRepository;
private final RideRepository rideRepository;
private final OrderMapper orderMapper;
private final AppUserService appUserService;
public OrderService(
OrderRepository orderRepository,
ProductRepository productRepository,
InventoryItemRepository inventoryItemRepository,
OrderMapper orderMapper
RideRepository rideRepository,
OrderMapper orderMapper,
AppUserService appUserService
) {
this.orderRepository = orderRepository;
this.productRepository = productRepository;
this.inventoryItemRepository = inventoryItemRepository;
this.rideRepository = rideRepository;
this.orderMapper = orderMapper;
this.appUserService = appUserService;
}
@Transactional
public OrderResponse placeOrder(CreateOrderRequest request) {
AppUser customer = appUserService.getCurrentUser();
if (customer.getRole() != Role.CUSTOMER) {
throw new IllegalArgumentException("Only customers can place orders");
}
Map<UUID, Integer> requestedQuantities = normalizeItems(request);
Map<UUID, Product> productsById = productRepository.findByIdIn(requestedQuantities.keySet())
@@ -69,8 +81,9 @@ public class OrderService {
validateStockAvailability(requestedQuantities, inventoryByProductId);
Order order = new Order();
order.setCustomerName(request.customerName());
order.setStatus(OrderStatus.PLACED);
order.setCustomer(customer);
order.setCustomerName(customer.getDisplayName());
order.setStatus(OrderStatus.PENDING);
order.setCurrency("KES");
order.setCreatedAt(Instant.now());
@@ -91,6 +104,18 @@ public class OrderService {
Order savedOrder = orderRepository.save(order);
if (request.deliveryRide() != null) {
Ride ride = new Ride()
.setOrder(savedOrder)
.setCustomer(customer)
.setPickupLocation(request.deliveryRide().pickupLocation().trim())
.setDropoffLocation(request.deliveryRide().dropoffLocation().trim())
.setDistanceKm(request.deliveryRide().distanceKm())
.setStatus(RideStatus.REQUESTED);
rideRepository.save(ride);
}
return orderMapper.toResponse(savedOrder);
}
@@ -99,6 +124,8 @@ public class OrderService {
Order order = orderRepository.findWithItemsById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
assertCanAccessOrder(order);
if (!order.isCancellable()) {
throw new OrderNotCancellableException(orderId);
}
@@ -137,6 +164,8 @@ public class OrderService {
Order order = orderRepository.findWithItemsById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
assertCanAccessOrder(order);
return orderMapper.toResponse(order);
}
@@ -157,7 +186,9 @@ public class OrderService {
Sort.by(Sort.Direction.DESC, "createdAt")
);
var result = orderRepository.findAll(buildOrderSpecification(status, from, to), pageable);
AppUser currentUser = appUserService.getCurrentUser();
var result = orderRepository.findAll(buildOrderSpecification(status, from, to, currentUser), pageable);
var items = result.getContent()
.stream()
@@ -173,6 +204,25 @@ public class OrderService {
);
}
@Transactional
public OrderResponse updateStatus(UUID orderId, OrderStatus status) {
AppUser currentUser = appUserService.getCurrentUser();
if (currentUser.getRole() != Role.ADMIN) {
throw new IllegalArgumentException("Only admins can update order status");
}
Order order = orderRepository.findWithItemsById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
if (status == OrderStatus.CANCELLED && order.isCancellable()) {
cancelOrder(orderId);
return orderMapper.toResponse(order);
}
order.updateStatus(status);
return orderMapper.toResponse(order);
}
private Map<UUID, Integer> normalizeItems(CreateOrderRequest request) {
Map<UUID, Integer> quantities = new HashMap<>();
@@ -236,7 +286,8 @@ public class OrderService {
private Specification<Order> buildOrderSpecification(
OrderStatus status,
LocalDate from,
LocalDate to
LocalDate to,
AppUser currentUser
) {
return (root, query, criteriaBuilder) -> {
var predicates = new java.util.ArrayList<Predicate>();
@@ -255,7 +306,29 @@ public class OrderService {
predicates.add(criteriaBuilder.lessThan(root.get("createdAt"), toExclusive));
}
if (currentUser.getRole() == Role.CUSTOMER) {
predicates.add(criteriaBuilder.equal(root.get("customer").get("id"), currentUser.getId()));
} else if (currentUser.getRole() != Role.ADMIN) {
predicates.add(criteriaBuilder.disjunction());
}
return criteriaBuilder.and(predicates.toArray(Predicate[]::new));
};
}
private void assertCanAccessOrder(Order order) {
AppUser currentUser = appUserService.getCurrentUser();
if (currentUser.getRole() == Role.ADMIN) {
return;
}
if (currentUser.getRole() == Role.CUSTOMER
&& order.getCustomer() != null
&& currentUser.getId().equals(order.getCustomer().getId())) {
return;
}
throw new IllegalArgumentException("You cannot access this order");
}
}
@@ -1,23 +1,143 @@
package com.andwati.orders.services;
import com.andwati.orders.dto.request.ProductRequest;
import com.andwati.orders.dto.response.ProductResponse;
import com.andwati.orders.exception.ProductNotFoundException;
import com.andwati.orders.mappers.ProductMapper;
import com.andwati.orders.model.InventoryItem;
import com.andwati.orders.model.Product;
import com.andwati.orders.model.Role;
import com.andwati.orders.repository.InventoryItemRepository;
import com.andwati.orders.repository.ProductRepository;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class ProductService {
private final ProductRepository productRepository;
private final InventoryItemRepository inventoryItemRepository;
private final ProductMapper productMapper;
private final AppUserService appUserService;
public ProductService(ProductRepository productRepository) {
public ProductService(
ProductRepository productRepository,
InventoryItemRepository inventoryItemRepository,
ProductMapper productMapper,
AppUserService appUserService
) {
this.productRepository = productRepository;
this.inventoryItemRepository = inventoryItemRepository;
this.productMapper = productMapper;
this.appUserService = appUserService;
}
@Transactional(readOnly = true)
public java.util.List<ProductResponse> listProducts() {
Role role = currentRoleOrCustomer();
var products = productRepository.findAll(Sort.by("name"));
var inventoriesByProductId = inventoryItemRepository.findByProduct_IdIn(
products.stream().map(Product::getId).toList()
)
.stream()
.collect(Collectors.toMap(item -> item.getProduct().getId(), Function.identity()));
return products.stream()
.filter(product -> role == Role.ADMIN || product.isActive())
.map(product -> productMapper.toResponse(product, inventoriesByProductId.get(product.getId())))
.toList();
}
@Transactional(readOnly = true)
public ProductResponse getProduct(UUID id) {
Product product = getProductOrThrow(id);
if (!product.isActive() && currentRoleOrCustomer() != Role.ADMIN) {
throw new ProductNotFoundException(id);
}
InventoryItem inventoryItem = inventoryItemRepository.findByProduct_Id(id).orElse(null);
return productMapper.toResponse(product, inventoryItem);
}
@Transactional
public ProductResponse createProduct(ProductRequest request) {
Product product = new Product()
.setName(request.name().trim())
.setSku(request.sku().trim())
.setPrice(request.price())
.setCurrency(request.currency().trim().toUpperCase())
.setActive(request.active())
.setCreatedAt(Instant.now())
.setUpdatedAt(Instant.now());
Product savedProduct = productRepository.save(product);
InventoryItem inventoryItem = new InventoryItem()
.setProduct(savedProduct)
.setAvailableQuantity(request.availableQuantity())
.setReservedQuantity(0)
.setReorderLevel(request.reorderLevel())
.setUpdatedAt(Instant.now());
inventoryItemRepository.save(inventoryItem);
return productMapper.toResponse(savedProduct, inventoryItem);
}
@Transactional
public ProductResponse updateProduct(UUID id, ProductRequest request) {
Product product = getProductOrThrow(id)
.setName(request.name().trim())
.setSku(request.sku().trim())
.setPrice(request.price())
.setCurrency(request.currency().trim().toUpperCase())
.setActive(request.active());
InventoryItem inventoryItem = inventoryItemRepository.findByProduct_Id(id)
.orElseGet(() -> new InventoryItem()
.setProduct(product)
.setReservedQuantity(0)
);
inventoryItem
.setAvailableQuantity(request.availableQuantity())
.setReorderLevel(request.reorderLevel())
.setUpdatedAt(Instant.now());
inventoryItemRepository.save(inventoryItem);
return productMapper.toResponse(product, inventoryItem);
}
@Transactional
public ProductResponse deactivateProduct(UUID id) {
Product product = getProductOrThrow(id).setActive(false);
InventoryItem inventoryItem = inventoryItemRepository.findByProduct_Id(id).orElse(null);
return productMapper.toResponse(product, inventoryItem);
}
public Product getProductOrThrow(UUID id) {
return productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
}
private Role currentRoleOrCustomer() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated() || "anonymousUser".equals(authentication.getName())) {
return Role.CUSTOMER;
}
return appUserService.getCurrentUser().getRole();
}
}
@@ -0,0 +1,170 @@
package com.andwati.orders.services;
import com.andwati.orders.dto.request.CreateRideRequest;
import com.andwati.orders.dto.response.RideResponse;
import com.andwati.orders.mappers.RideMapper;
import com.andwati.orders.model.AppUser;
import com.andwati.orders.model.Order;
import com.andwati.orders.model.Ride;
import com.andwati.orders.model.RideStatus;
import com.andwati.orders.model.Role;
import com.andwati.orders.repository.OrderRepository;
import com.andwati.orders.repository.RideRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
@Service
public class RideService {
private final RideRepository rideRepository;
private final OrderRepository orderRepository;
private final AppUserService appUserService;
private final FareCalculationService fareCalculationService;
private final RideMapper rideMapper;
public RideService(
RideRepository rideRepository,
OrderRepository orderRepository,
AppUserService appUserService,
FareCalculationService fareCalculationService,
RideMapper rideMapper
) {
this.rideRepository = rideRepository;
this.orderRepository = orderRepository;
this.appUserService = appUserService;
this.fareCalculationService = fareCalculationService;
this.rideMapper = rideMapper;
}
@Transactional
public RideResponse requestRide(CreateRideRequest request) {
AppUser customer = appUserService.getCurrentUser();
if (customer.getRole() != Role.CUSTOMER) {
throw new IllegalArgumentException("Only customers can request rides");
}
Order order = orderRepository.findWithItemsById(request.orderId())
.orElseThrow(() -> new IllegalArgumentException("Order was not found"));
if (order.getCustomer() == null || !customer.getId().equals(order.getCustomer().getId())) {
throw new IllegalArgumentException("You can only request rides for your own orders");
}
if (rideRepository.existsByOrder_Id(order.getId())) {
throw new IllegalArgumentException("This order already has a ride request");
}
Ride ride = new Ride()
.setOrder(order)
.setCustomer(customer)
.setPickupLocation(request.pickupLocation().trim())
.setDropoffLocation(request.dropoffLocation().trim())
.setDistanceKm(request.distanceKm())
.setStatus(RideStatus.REQUESTED);
return rideMapper.toResponse(rideRepository.save(ride));
}
@Transactional(readOnly = true)
public List<RideResponse> listRides() {
AppUser currentUser = appUserService.getCurrentUser();
List<Ride> rides = switch (currentUser.getRole()) {
case ADMIN -> rideRepository.findAllByOrderByRequestedAtDesc();
case CUSTOMER -> rideRepository.findByCustomer_IdOrderByRequestedAtDesc(currentUser.getId());
case DRIVER -> {
var requested = rideRepository.findByStatusOrderByRequestedAtAsc(RideStatus.REQUESTED);
var assigned = rideRepository.findByDriver_IdOrderByRequestedAtDesc(currentUser.getId());
yield java.util.stream.Stream.concat(requested.stream(), assigned.stream())
.distinct()
.toList();
}
};
return rides.stream().map(rideMapper::toResponse).toList();
}
@Transactional
public RideResponse acceptRide(UUID id) {
AppUser driver = appUserService.getCurrentUser();
if (driver.getRole() != Role.DRIVER) {
throw new IllegalArgumentException("Only drivers can accept rides");
}
Ride ride = getRideOrThrow(id);
var fare = fareCalculationService.calculateFare(ride.getDistanceKm(), null);
ride.accept(driver, fare.calculatedFare(), fare.currency());
return rideMapper.toResponse(ride);
}
@Transactional
public RideResponse completeRide(UUID id) {
AppUser driver = appUserService.getCurrentUser();
if (driver.getRole() != Role.DRIVER) {
throw new IllegalArgumentException("Only drivers can complete rides");
}
Ride ride = getRideOrThrow(id);
ride.complete(driver);
completeAttachedOrder(ride);
return rideMapper.toResponse(ride);
}
@Transactional
public RideResponse cancelRide(UUID id) {
AppUser currentUser = appUserService.getCurrentUser();
Ride ride = getRideOrThrow(id);
if (currentUser.getRole() == Role.CUSTOMER
&& !currentUser.getId().equals(ride.getCustomer().getId())) {
throw new IllegalArgumentException("You cannot cancel this ride");
}
if (currentUser.getRole() == Role.DRIVER) {
throw new IllegalArgumentException("Drivers cannot cancel rides");
}
ride.cancel();
return rideMapper.toResponse(ride);
}
@Transactional
public RideResponse updateStatus(UUID id, RideStatus status) {
AppUser currentUser = appUserService.getCurrentUser();
if (currentUser.getRole() != Role.ADMIN) {
throw new IllegalArgumentException("Only admins can update ride status");
}
Ride ride = getRideOrThrow(id);
if (status == RideStatus.CANCELLED) {
ride.cancel();
} else if (status == RideStatus.COMPLETED) {
if (ride.getDriver() == null) {
throw new IllegalArgumentException("A ride must have a driver before completion");
}
ride.setStatus(RideStatus.COMPLETED).setCompletedAt(java.time.Instant.now());
completeAttachedOrder(ride);
} else {
throw new IllegalArgumentException("Admins can only cancel or complete rides");
}
return rideMapper.toResponse(ride);
}
private Ride getRideOrThrow(UUID id) {
return rideRepository.findWithUsersById(id)
.orElseThrow(() -> new IllegalArgumentException("Ride was not found"));
}
private void completeAttachedOrder(Ride ride) {
if (ride.getOrder() != null) {
ride.getOrder().markDeliveredFromRide();
}
}
}
@@ -1,10 +1,10 @@
server.port=8080
server.port=${PORT:8080}
spring.application.name=orders
spring.datasource.url=${DATABASE_URL}
spring.datasource.username=${DATABASE_USERNAME}
spring.datasource.password=${DATABASE_PASSWORD}
spring.datasource.url=${SPRING_DATASOURCE_URL:${DATABASE_URL:}}
spring.datasource.username=${SPRING_DATASOURCE_USERNAME:${DATABASE_USERNAME:}}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:${DATABASE_PASSWORD:}}
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=update
@@ -21,8 +21,9 @@ fare.default-surge-multiplier=${FARE_DEFAULT_SURGE_MULTIPLIER:1.0}
fare.currency=${FARE_CURRENCY:KES}
app.seed-data=${SEED_DATA:true}
app.cors.allowed-origins=${APP_CORS_ALLOWED_ORIGINS:http://localhost:3000}
springdoc.api-docs.path=/api-docs
springdoc.swagger-ui.path=/swagger-ui.html
springdoc.swagger-ui.operations-sorter=method
springdoc.swagger-ui.tags-sorter=alpha
springdoc.swagger-ui.try-it-out-enabled=true
springdoc.swagger-ui.try-it-out-enabled=true
@@ -2,8 +2,10 @@ package com.andwati.orders;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
@SpringBootTest
@ActiveProfiles("test")
class OrdersBackendApplicationTests {
@Test
+14
View File
@@ -0,0 +1,14 @@
.env
node_modules
.output
dist
dist-ssr
.nitro
.tanstack
.vinxi
.wrangler
.idea
.vscode
Dockerfile
.dockerignore
+29
View File
@@ -0,0 +1,29 @@
FROM node:22-bookworm-slim AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile --config.optional=true
COPY . .
ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN pnpm build
FROM node:22-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
COPY --from=build /app/.output ./.output
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
+10 -7
View File
@@ -16,7 +16,12 @@
"check": "biome check"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@faker-js/faker": "^10.3.0",
"@tabler/icons-react": "^3.44.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/match-sorter-utils": "latest",
"@tanstack/react-devtools": "latest",
@@ -34,14 +39,18 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"next-themes": "^0.4.6",
"nitro": "npm:nitro-nightly@latest",
"radix-ui": "^1.5.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"recharts": "3.8.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.3.6",
"zod": "^4.3.6"
"vaul": "^1.1.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "2.4.5",
@@ -61,11 +70,5 @@
"typescript": "^6.0.2",
"vite": "^8.0.0",
"vitest": "^4.1.5"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"lightningcss"
]
}
}
+430 -1
View File
@@ -8,9 +8,24 @@ importers:
.:
dependencies:
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1([email protected]([email protected]))([email protected])
'@dnd-kit/modifiers':
specifier: ^9.0.0
version: 9.0.0(@dnd-kit/[email protected]([email protected]([email protected]))([email protected]))([email protected])
'@dnd-kit/sortable':
specifier: ^10.0.0
version: 10.0.0(@dnd-kit/[email protected]([email protected]([email protected]))([email protected]))([email protected])
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2([email protected])
'@faker-js/faker':
specifier: ^10.3.0
version: 10.4.0
'@tabler/icons-react':
specifier: ^3.44.0
version: 3.44.0([email protected])
'@tailwindcss/vite':
specifier: ^4.1.18
version: 4.3.0([email protected](@types/[email protected])([email protected]))
@@ -62,6 +77,9 @@ importers:
lucide-react:
specifier: ^0.577.0
version: 0.577.0([email protected])
next-themes:
specifier: ^0.4.6
version: 0.4.6([email protected]([email protected]))([email protected])
nitro:
specifier: npm:nitro-nightly@latest
version: [email protected]([email protected])([email protected])([email protected])([email protected])([email protected](@types/[email protected])([email protected]))
@@ -74,6 +92,12 @@ importers:
react-dom:
specifier: ^19.2.0
version: 19.2.7([email protected])
recharts:
specifier: 3.8.0
version: 3.8.0(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
sonner:
specifier: ^2.0.7
version: 2.0.7([email protected]([email protected]))([email protected])
tailwind-merge:
specifier: ^3.0.2
version: 3.6.0
@@ -83,8 +107,11 @@ importers:
tw-animate-css:
specifier: ^1.3.6
version: 1.4.0
vaul:
specifier: ^1.1.2
version: 1.1.2(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
zod:
specifier: ^4.3.6
specifier: ^4.4.3
version: 4.4.3
devDependencies:
'@biomejs/biome':
@@ -330,6 +357,34 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@dnd-kit/[email protected]':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/[email protected]':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/[email protected]':
resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/[email protected]':
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/[email protected]':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
'@emnapi/[email protected]':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@@ -1240,6 +1295,17 @@ packages:
'@radix-ui/[email protected]':
resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==}
'@reduxjs/[email protected]':
resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@rolldown/[email protected]':
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1621,6 +1687,17 @@ packages:
'@standard-schema/[email protected]':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/[email protected]':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@tabler/[email protected]':
resolution: {integrity: sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==}
peerDependencies:
react: '>= 16'
'@tabler/[email protected]':
resolution: {integrity: sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==}
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==}
@@ -2024,6 +2101,33 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/[email protected]':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/[email protected]':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/[email protected]':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/[email protected]':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/[email protected]':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/[email protected]':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/[email protected]':
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
'@types/[email protected]':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/[email protected]':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/[email protected]':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@@ -2041,6 +2145,9 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
'@types/[email protected]':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@vitejs/[email protected]':
resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2203,6 +2310,50 @@ packages:
[email protected]:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
[email protected]:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -2242,6 +2393,9 @@ packages:
supports-color:
optional: true
[email protected]:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
[email protected]:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -2298,6 +2452,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
[email protected]:
resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==}
[email protected]:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
@@ -2305,6 +2462,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
[email protected]:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
[email protected]:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -2387,6 +2547,16 @@ packages:
[email protected]:
resolution: {integrity: sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==}
[email protected]:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
[email protected]:
resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==}
[email protected]:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
@@ -2535,6 +2705,12 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
[email protected]:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
[email protected]:
resolution: {integrity: sha512-N9zEWySuJFw+gR0lhS5863YsvNeudOdqRyFvNb+jMXbeTJOdrjDqkCpDginIZfUm0LzT1t1nCRiDeqQm/8kirQ==}
@@ -2645,6 +2821,18 @@ packages:
[email protected]:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
[email protected]:
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
[email protected]:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'}
@@ -2683,6 +2871,22 @@ packages:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
[email protected]:
resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
[email protected]:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
[email protected]:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
[email protected]:
resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==}
@@ -2694,6 +2898,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
[email protected]:
resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2743,6 +2950,12 @@ packages:
[email protected]:
resolution: {integrity: sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ==}
[email protected]:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
[email protected]:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -2783,6 +2996,9 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
[email protected]:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -2949,6 +3165,15 @@ packages:
[email protected]:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
[email protected]:
resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
[email protected]:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
[email protected]:
resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -3305,6 +3530,38 @@ snapshots:
'@csstools/[email protected]': {}
'@dnd-kit/[email protected]([email protected])':
dependencies:
react: 19.2.7
tslib: 2.8.1
'@dnd-kit/[email protected]([email protected]([email protected]))([email protected])':
dependencies:
'@dnd-kit/accessibility': 3.1.1([email protected])
'@dnd-kit/utilities': 3.2.2([email protected])
react: 19.2.7
react-dom: 19.2.7([email protected])
tslib: 2.8.1
'@dnd-kit/[email protected](@dnd-kit/[email protected]([email protected]([email protected]))([email protected]))([email protected])':
dependencies:
'@dnd-kit/core': 6.3.1([email protected]([email protected]))([email protected])
'@dnd-kit/utilities': 3.2.2([email protected])
react: 19.2.7
tslib: 2.8.1
'@dnd-kit/[email protected](@dnd-kit/[email protected]([email protected]([email protected]))([email protected]))([email protected])':
dependencies:
'@dnd-kit/core': 6.3.1([email protected]([email protected]))([email protected])
'@dnd-kit/utilities': 3.2.2([email protected])
react: 19.2.7
tslib: 2.8.1
'@dnd-kit/[email protected]([email protected])':
dependencies:
react: 19.2.7
tslib: 2.8.1
'@emnapi/[email protected]':
dependencies:
'@emnapi/wasi-threads': 1.2.1
@@ -4224,6 +4481,18 @@ snapshots:
'@radix-ui/[email protected]': {}
'@reduxjs/[email protected]([email protected](@types/[email protected])([email protected])([email protected]))([email protected])':
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
immer: 11.1.8
redux: 5.0.1
redux-thunk: 3.1.0([email protected])
reselect: 5.1.1
optionalDependencies:
react: 19.2.7
react-redux: 9.3.0(@types/[email protected])([email protected])([email protected])
'@rolldown/[email protected]':
optional: true
@@ -4444,6 +4713,15 @@ snapshots:
'@standard-schema/[email protected]': {}
'@standard-schema/[email protected]': {}
'@tabler/[email protected]([email protected])':
dependencies:
'@tabler/icons': 3.44.0
react: 19.2.7
'@tabler/[email protected]': {}
'@tailwindcss/[email protected]':
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -4904,6 +5182,30 @@ snapshots:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/[email protected]': {}
'@types/[email protected]': {}
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
'@types/d3-color': 3.1.3
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
'@types/d3-time': 3.0.4
'@types/[email protected]':
dependencies:
'@types/d3-path': 3.1.1
'@types/[email protected]': {}
'@types/[email protected]': {}
'@types/[email protected]': {}
'@types/[email protected]': {}
@@ -4920,6 +5222,8 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/[email protected]': {}
'@vitejs/[email protected](@rolldown/[email protected](@babel/[email protected])(@babel/[email protected])([email protected])([email protected](@types/[email protected])([email protected])))([email protected])([email protected](@types/[email protected])([email protected]))':
dependencies:
'@rolldown/pluginutils': 1.0.1
@@ -5074,6 +5378,44 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
internmap: 2.0.3
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
d3-color: 3.1.0
[email protected]: {}
[email protected]:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.2
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
[email protected]:
dependencies:
d3-path: 3.1.0
[email protected]:
dependencies:
d3-time: 3.1.0
[email protected]:
dependencies:
d3-array: 3.2.4
[email protected]: {}
[email protected]:
dependencies:
whatwg-mimetype: 5.0.0
@@ -5089,6 +5431,8 @@ snapshots:
dependencies:
ms: 2.1.3
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -5121,12 +5465,16 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
'@types/estree': 1.0.9
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -5190,6 +5538,12 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -5311,6 +5665,11 @@ snapshots:
[email protected]: {}
[email protected]([email protected]([email protected]))([email protected]):
dependencies:
react: 19.2.7
react-dom: 19.2.7([email protected])
[email protected]: {}
[email protected]([email protected])([email protected])([email protected])([email protected])([email protected](@types/[email protected])([email protected])):
@@ -5506,6 +5865,15 @@ snapshots:
[email protected]: {}
[email protected](@types/[email protected])([email protected])([email protected]):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.2.7
use-sync-external-store: 1.6.0([email protected])
optionalDependencies:
'@types/react': 19.2.17
redux: 5.0.1
[email protected](@types/[email protected])([email protected]):
dependencies:
react: 19.2.7
@@ -5537,12 +5905,40 @@ snapshots:
[email protected]: {}
[email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]):
dependencies:
'@reduxjs/toolkit': 2.12.0([email protected](@types/[email protected])([email protected])([email protected]))([email protected])
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.47.0
eventemitter3: 5.0.4
immer: 10.2.0
react: 19.2.7
react-dom: 19.2.7([email protected])
react-is: 17.0.2
react-redux: 9.3.0(@types/[email protected])([email protected])([email protected])
reselect: 5.1.1
tiny-invariant: 1.3.3
use-sync-external-store: 1.6.0([email protected])
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
- redux
[email protected]([email protected]):
dependencies:
redux: 5.0.1
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
'@oxc-project/types': 0.133.0
@@ -5643,6 +6039,11 @@ snapshots:
seroval: 1.5.4
seroval-plugins: 1.5.4([email protected])
[email protected]([email protected]([email protected]))([email protected]):
dependencies:
react: 19.2.7
react-dom: 19.2.7([email protected])
[email protected]: {}
[email protected]: {}
@@ -5671,6 +6072,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -5752,6 +6155,32 @@ snapshots:
[email protected]: {}
[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]):
dependencies:
'@radix-ui/react-dialog': 1.1.16(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
react: 19.2.7
react-dom: 19.2.7([email protected])
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
[email protected]:
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.8
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
[email protected](@types/[email protected])([email protected]):
dependencies:
lightningcss: 1.32.0
+25
View File
@@ -0,0 +1,25 @@
import {apiRequest, clearRequestSecurity} from "#/api/client";
import type {AuthUser, LoginRequest, RegisterRequest} from "#/types/auth";
export function getCurrentUser(signal?: AbortSignal) {
return apiRequest<AuthUser>("/auth/me", {signal});
}
export function login(request: LoginRequest) {
return apiRequest<AuthUser>("/auth/login", {
method: "POST",
body: request,
});
}
export function register(request: RegisterRequest) {
return apiRequest<AuthUser>("/auth/register", {
method: "POST",
body: request,
});
}
export async function logout() {
await apiRequest<void>("/auth/logout", {method: "POST"});
clearRequestSecurity();
}
+51 -3
View File
@@ -3,6 +3,9 @@ import type {ApiErrorResponse} from "#/types/api";
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080/api";
let csrfToken: string | null = null;
let csrfHeaderName = "X-XSRF-TOKEN";
export class ApiError extends Error {
status: number;
code: string;
@@ -23,14 +26,46 @@ type RequestOptions = {
signal?: AbortSignal;
};
type CsrfResponse = {
headerName: string;
parameterName: string;
token: string;
};
async function loadCsrfToken() {
const response = await fetch(`${API_BASE_URL}/auth/csrf`, {
credentials: "include",
});
if (!response.ok) {
throw new Error("Could not initialize request security");
}
const data = (await response.json()) as CsrfResponse;
csrfToken = data.token;
csrfHeaderName = data.headerName;
}
function isMutating(method: string) {
return method !== "GET";
}
export async function apiRequest<T>(
path: string,
options: RequestOptions = {},
): Promise<T> {
const method = options.method ?? "GET";
if (isMutating(method) && !csrfToken) {
await loadCsrfToken();
}
const response = await fetch(`${API_BASE_URL}${path}`, {
method: options.method ?? "GET",
method,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(isMutating(method) && csrfToken ? {[csrfHeaderName]: csrfToken} : {}),
},
body: options.body ? JSON.stringify(options.body) : undefined,
signal: options.signal,
@@ -58,5 +93,18 @@ export async function apiRequest<T>(
return undefined as T;
}
return response.json() as Promise<T>;
}
const text = await response.text();
if (!text) {
return undefined as T;
}
return JSON.parse(text) as T;
}
export function clearRequestSecurity() {
csrfToken = null;
}
export async function initializeRequestSecurity() {
await loadCsrfToken();
}
+23 -2
View File
@@ -1,6 +1,13 @@
import {apiRequest} from "#/api/client";
import type {PageResponse} from "#/types/api";
import type {CancelOrderResponse, Order, OrderFilters, OrderSummary,} from "#/types/order";
import type {
CancelOrderResponse,
CreateOrderRequest,
Order,
OrderFilters,
OrderStatus,
OrderSummary,
} from "#/types/order";
export function getOrders(
filters: OrderFilters,
@@ -34,8 +41,22 @@ export function getOrder(id: string, signal?: AbortSignal) {
return apiRequest<Order>(`/orders/${id}`, {signal});
}
export function createOrder(request: CreateOrderRequest) {
return apiRequest<Order>("/orders", {
method: "POST",
body: request,
});
}
export function cancelOrder(id: string) {
return apiRequest<CancelOrderResponse>(`/orders/${id}`, {
method: "DELETE",
});
}
}
export function updateOrderStatus(id: string, status: OrderStatus) {
return apiRequest<Order>(`/orders/${id}/status`, {
method: "PATCH",
body: {status},
});
}
+28
View File
@@ -0,0 +1,28 @@
import {apiRequest} from "#/api/client";
import type {Product, ProductRequest} from "#/types/product";
export function getProducts(signal?: AbortSignal) {
return apiRequest<Product[]>("/products", {signal});
}
export function getProduct(id: string, signal?: AbortSignal) {
return apiRequest<Product>(`/products/${id}`, {signal});
}
export function createProduct(request: ProductRequest) {
return apiRequest<Product>("/products", {
method: "POST",
body: request,
});
}
export function updateProduct(id: string, request: ProductRequest) {
return apiRequest<Product>(`/products/${id}`, {
method: "PUT",
body: request,
});
}
export function deactivateProduct(id: string) {
return apiRequest<Product>(`/products/${id}`, {method: "DELETE"});
}
+32
View File
@@ -0,0 +1,32 @@
import {apiRequest} from "#/api/client";
import type {CreateRideRequest, Ride, RideStatus} from "#/types/ride";
export function getRides(signal?: AbortSignal) {
return apiRequest<Ride[]>("/rides", {signal});
}
export function requestRide(request: CreateRideRequest) {
return apiRequest<Ride>("/rides", {
method: "POST",
body: request,
});
}
export function acceptRide(id: string) {
return apiRequest<Ride>(`/rides/${id}/accept`, {method: "POST"});
}
export function completeRide(id: string) {
return apiRequest<Ride>(`/rides/${id}/complete`, {method: "POST"});
}
export function cancelRide(id: string) {
return apiRequest<Ride>(`/rides/${id}`, {method: "DELETE"});
}
export function updateRideStatus(id: string, status: RideStatus) {
return apiRequest<Ride>(`/rides/${id}/status`, {
method: "PATCH",
body: {status},
});
}
+32
View File
@@ -0,0 +1,32 @@
import {apiRequest} from "#/api/client";
import type {AdminUserRequest, UserSummary} from "#/types/user";
export function getUsers(signal?: AbortSignal) {
return apiRequest<UserSummary[]>("/users", {signal});
}
export function getCustomers(signal?: AbortSignal) {
return apiRequest<UserSummary[]>("/users/customers", {signal});
}
export function getDrivers(signal?: AbortSignal) {
return apiRequest<UserSummary[]>("/users/drivers", {signal});
}
export function createUser(request: AdminUserRequest) {
return apiRequest<UserSummary>("/users", {
method: "POST",
body: request,
});
}
export function updateUser(id: string, request: AdminUserRequest) {
return apiRequest<UserSummary>(`/users/${id}`, {
method: "PUT",
body: request,
});
}
export function disableUser(id: string) {
return apiRequest<UserSummary>(`/users/${id}`, {method: "DELETE"});
}
@@ -0,0 +1,199 @@
"use client"
import * as React from "react"
import {
Boxes,
Car,
GalleryVerticalEnd,
LayoutDashboard,
ReceiptText,
ShoppingCart,
Store,
Truck,
Users,
} from "lucide-react"
import { NavMain } from "#/components/nav-main.tsx"
import { NavUser } from "#/components/nav-user.tsx"
import { TeamSwitcher } from "#/components/team-switcher.tsx"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarRail,
} from "#/components/ui/sidebar.tsx"
import type { AuthUser, Role } from "#/types/auth.ts"
const roleLabels: Record<Role, string> = {
ADMIN: "Admin workspace",
CUSTOMER: "Customer store",
DRIVER: "Driver workspace",
}
const navMainByRole: Record<Role, React.ComponentProps<typeof NavMain>["items"]> = {
ADMIN: [
{
title: "Overview",
url: "/admin",
icon: LayoutDashboard,
isActive: true,
items: [
{ title: "Operations", url: "/admin" },
{ title: "Inventory health", url: "/admin/inventory" },
],
},
{
title: "Products",
url: "/admin/products",
icon: Boxes,
items: [
{ title: "Product catalog", url: "/admin/products" },
{ title: "Create product", url: "/admin/products/new" },
],
},
{
title: "Orders",
url: "/admin/orders",
icon: ReceiptText,
items: [
{ title: "Pending orders", url: "/admin/orders/pending" },
{ title: "Shipment status", url: "/admin/orders" },
],
},
{
title: "Rides",
url: "/admin/rides",
icon: Car,
items: [
{ title: "Ride requests", url: "/admin/rides/requested" },
{ title: "Driver assignments", url: "/admin/rides/accepted" },
],
},
{
title: "Users",
url: "/admin/users",
icon: Users,
items: [
{ title: "All users", url: "/admin/users" },
{ title: "Customers", url: "/admin/customers" },
{ title: "Drivers", url: "/admin/drivers" },
],
},
],
CUSTOMER: [
{
title: "Overview",
url: "/",
icon: LayoutDashboard,
isActive: true,
items: [
{ title: "Dashboard", url: "/" },
{ title: "Order history", url: "/orders" },
],
},
{
title: "Storefront",
url: "/products",
icon: Store,
items: [
{ title: "Product listing", url: "/products" },
{ title: "Featured inventory", url: "/products/featured" },
],
},
{
title: "Cart",
url: "/cart",
icon: ShoppingCart,
items: [
{ title: "Review cart", url: "/cart" },
{ title: "Checkout", url: "/cart" },
],
},
{
title: "Orders",
url: "/orders",
icon: ReceiptText,
items: [
{ title: "Order history", url: "/orders" },
{ title: "Delivery status", url: "/orders/status" },
],
},
{
title: "Rides",
url: "/rides",
icon: Car,
items: [
{ title: "Request a ride", url: "/rides/new" },
{ title: "Ride history", url: "/rides" },
],
},
],
DRIVER: [
{
title: "Overview",
url: "/",
icon: LayoutDashboard,
isActive: true,
items: [
{ title: "Dashboard", url: "/" },
{ title: "Completed rides", url: "/driver/rides/completed" },
],
},
{
title: "Ride board",
url: "/driver/rides",
icon: Car,
items: [
{ title: "Requested rides", url: "/driver/rides/requested" },
{ title: "Accepted rides", url: "/driver/rides/accepted" },
],
},
{
title: "Routes",
url: "/driver/rides/accepted",
icon: Truck,
items: [
{ title: "Active route", url: "/driver/rides/accepted" },
{ title: "Completed rides", url: "/driver/rides/completed" },
],
},
],
}
type AppSidebarProps = React.ComponentProps<typeof Sidebar> & {
user: AuthUser
onLogout?: () => void
}
export function AppSidebar({ user, onLogout, ...props }: AppSidebarProps) {
return (
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<TeamSwitcher
teams={[
{
name: "Cymelle",
logo: GalleryVerticalEnd,
plan: roleLabels[user.role],
},
]}
/>
</SidebarHeader>
<SidebarContent>
<NavMain items={navMainByRole[user.role]} />
</SidebarContent>
<SidebarFooter>
<NavUser
user={{
name: user.displayName,
email: user.username,
avatar: "",
}}
onLogout={onLogout}
/>
</SidebarFooter>
<SidebarRail />
</Sidebar>
)
}
@@ -0,0 +1,81 @@
import {AppSidebar} from "#/components/app-sidebar";
import {SiteHeader} from "#/components/site-header";
import {StoreHome} from "#/components/store/StoreHome";
import {Card, CardContent, CardHeader} from "#/components/ui/card";
import {Skeleton} from "#/components/ui/skeleton";
import {SidebarInset, SidebarProvider} from "#/components/ui/sidebar";
import {useAuth} from "#/hooks/useAuth";
import type {Role} from "#/types/auth";
import type {CSSProperties, ReactNode} from "react";
type AppShellProps = {
title: string;
allowedRoles?: Role[];
showCart?: boolean;
children: ReactNode;
};
export function AppShell({title, allowedRoles, showCart = false, children}: AppShellProps) {
const {user, isLoading, logout} = useAuth();
if (isLoading && !user) {
return <WorkspaceSkeleton/>;
}
if (!user) {
return <StoreHome/>;
}
const allowed = !allowedRoles || allowedRoles.includes(user.role);
return (
<SidebarProvider
style={{
"--sidebar-width": "18rem",
"--header-height": "3.5rem",
} as CSSProperties}
>
<AppSidebar user={user} onLogout={logout} variant="inset"/>
<SidebarInset>
<SiteHeader title={title} onLogout={logout} showCart={showCart || user.role === "CUSTOMER"}/>
<main className="@container/main flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">
{allowed ? children : <AccessDenied/>}
</main>
</SidebarInset>
</SidebarProvider>
);
}
function WorkspaceSkeleton() {
return (
<main className="min-h-screen bg-background p-4 md:p-6">
<div className="mx-auto max-w-6xl space-y-4">
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-48"/>
<Skeleton className="h-9 w-32"/>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Skeleton className="h-28"/>
<Skeleton className="h-28"/>
<Skeleton className="h-28"/>
</div>
<Skeleton className="h-80"/>
</div>
</main>
);
}
function AccessDenied() {
return (
<Card>
<CardHeader>
<h2 className="text-lg font-semibold">This page is not available for your role</h2>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Switch to the correct account type to view this workspace.
</p>
</CardContent>
</Card>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,291 @@
"use client"
import * as React from "react"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import { useIsMobile } from "#/hooks/use-mobile.ts"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "#/components/ui/card.tsx"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "#/components/ui/chart.tsx"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "#/components/ui/select.tsx"
import {
ToggleGroup,
ToggleGroupItem,
} from "#/components/ui/toggle-group.tsx"
export const description = "An interactive area chart"
const chartData = [
{ date: "2024-04-01", desktop: 222, mobile: 150 },
{ date: "2024-04-02", desktop: 97, mobile: 180 },
{ date: "2024-04-03", desktop: 167, mobile: 120 },
{ date: "2024-04-04", desktop: 242, mobile: 260 },
{ date: "2024-04-05", desktop: 373, mobile: 290 },
{ date: "2024-04-06", desktop: 301, mobile: 340 },
{ date: "2024-04-07", desktop: 245, mobile: 180 },
{ date: "2024-04-08", desktop: 409, mobile: 320 },
{ date: "2024-04-09", desktop: 59, mobile: 110 },
{ date: "2024-04-10", desktop: 261, mobile: 190 },
{ date: "2024-04-11", desktop: 327, mobile: 350 },
{ date: "2024-04-12", desktop: 292, mobile: 210 },
{ date: "2024-04-13", desktop: 342, mobile: 380 },
{ date: "2024-04-14", desktop: 137, mobile: 220 },
{ date: "2024-04-15", desktop: 120, mobile: 170 },
{ date: "2024-04-16", desktop: 138, mobile: 190 },
{ date: "2024-04-17", desktop: 446, mobile: 360 },
{ date: "2024-04-18", desktop: 364, mobile: 410 },
{ date: "2024-04-19", desktop: 243, mobile: 180 },
{ date: "2024-04-20", desktop: 89, mobile: 150 },
{ date: "2024-04-21", desktop: 137, mobile: 200 },
{ date: "2024-04-22", desktop: 224, mobile: 170 },
{ date: "2024-04-23", desktop: 138, mobile: 230 },
{ date: "2024-04-24", desktop: 387, mobile: 290 },
{ date: "2024-04-25", desktop: 215, mobile: 250 },
{ date: "2024-04-26", desktop: 75, mobile: 130 },
{ date: "2024-04-27", desktop: 383, mobile: 420 },
{ date: "2024-04-28", desktop: 122, mobile: 180 },
{ date: "2024-04-29", desktop: 315, mobile: 240 },
{ date: "2024-04-30", desktop: 454, mobile: 380 },
{ date: "2024-05-01", desktop: 165, mobile: 220 },
{ date: "2024-05-02", desktop: 293, mobile: 310 },
{ date: "2024-05-03", desktop: 247, mobile: 190 },
{ date: "2024-05-04", desktop: 385, mobile: 420 },
{ date: "2024-05-05", desktop: 481, mobile: 390 },
{ date: "2024-05-06", desktop: 498, mobile: 520 },
{ date: "2024-05-07", desktop: 388, mobile: 300 },
{ date: "2024-05-08", desktop: 149, mobile: 210 },
{ date: "2024-05-09", desktop: 227, mobile: 180 },
{ date: "2024-05-10", desktop: 293, mobile: 330 },
{ date: "2024-05-11", desktop: 335, mobile: 270 },
{ date: "2024-05-12", desktop: 197, mobile: 240 },
{ date: "2024-05-13", desktop: 197, mobile: 160 },
{ date: "2024-05-14", desktop: 448, mobile: 490 },
{ date: "2024-05-15", desktop: 473, mobile: 380 },
{ date: "2024-05-16", desktop: 338, mobile: 400 },
{ date: "2024-05-17", desktop: 499, mobile: 420 },
{ date: "2024-05-18", desktop: 315, mobile: 350 },
{ date: "2024-05-19", desktop: 235, mobile: 180 },
{ date: "2024-05-20", desktop: 177, mobile: 230 },
{ date: "2024-05-21", desktop: 82, mobile: 140 },
{ date: "2024-05-22", desktop: 81, mobile: 120 },
{ date: "2024-05-23", desktop: 252, mobile: 290 },
{ date: "2024-05-24", desktop: 294, mobile: 220 },
{ date: "2024-05-25", desktop: 201, mobile: 250 },
{ date: "2024-05-26", desktop: 213, mobile: 170 },
{ date: "2024-05-27", desktop: 420, mobile: 460 },
{ date: "2024-05-28", desktop: 233, mobile: 190 },
{ date: "2024-05-29", desktop: 78, mobile: 130 },
{ date: "2024-05-30", desktop: 340, mobile: 280 },
{ date: "2024-05-31", desktop: 178, mobile: 230 },
{ date: "2024-06-01", desktop: 178, mobile: 200 },
{ date: "2024-06-02", desktop: 470, mobile: 410 },
{ date: "2024-06-03", desktop: 103, mobile: 160 },
{ date: "2024-06-04", desktop: 439, mobile: 380 },
{ date: "2024-06-05", desktop: 88, mobile: 140 },
{ date: "2024-06-06", desktop: 294, mobile: 250 },
{ date: "2024-06-07", desktop: 323, mobile: 370 },
{ date: "2024-06-08", desktop: 385, mobile: 320 },
{ date: "2024-06-09", desktop: 438, mobile: 480 },
{ date: "2024-06-10", desktop: 155, mobile: 200 },
{ date: "2024-06-11", desktop: 92, mobile: 150 },
{ date: "2024-06-12", desktop: 492, mobile: 420 },
{ date: "2024-06-13", desktop: 81, mobile: 130 },
{ date: "2024-06-14", desktop: 426, mobile: 380 },
{ date: "2024-06-15", desktop: 307, mobile: 350 },
{ date: "2024-06-16", desktop: 371, mobile: 310 },
{ date: "2024-06-17", desktop: 475, mobile: 520 },
{ date: "2024-06-18", desktop: 107, mobile: 170 },
{ date: "2024-06-19", desktop: 341, mobile: 290 },
{ date: "2024-06-20", desktop: 408, mobile: 450 },
{ date: "2024-06-21", desktop: 169, mobile: 210 },
{ date: "2024-06-22", desktop: 317, mobile: 270 },
{ date: "2024-06-23", desktop: 480, mobile: 530 },
{ date: "2024-06-24", desktop: 132, mobile: 180 },
{ date: "2024-06-25", desktop: 141, mobile: 190 },
{ date: "2024-06-26", desktop: 434, mobile: 380 },
{ date: "2024-06-27", desktop: 448, mobile: 490 },
{ date: "2024-06-28", desktop: 149, mobile: 200 },
{ date: "2024-06-29", desktop: 103, mobile: 160 },
{ date: "2024-06-30", desktop: 446, mobile: 400 },
]
const chartConfig = {
visitors: {
label: "Visitors",
},
desktop: {
label: "Desktop",
color: "var(--primary)",
},
mobile: {
label: "Mobile",
color: "var(--primary)",
},
} satisfies ChartConfig
export function ChartAreaInteractive() {
const isMobile = useIsMobile()
const [timeRange, setTimeRange] = React.useState("90d")
React.useEffect(() => {
if (isMobile) {
setTimeRange("7d")
}
}, [isMobile])
const filteredData = chartData.filter((item) => {
const date = new Date(item.date)
const referenceDate = new Date("2024-06-30")
let daysToSubtract = 90
if (timeRange === "30d") {
daysToSubtract = 30
} else if (timeRange === "7d") {
daysToSubtract = 7
}
const startDate = new Date(referenceDate)
startDate.setDate(startDate.getDate() - daysToSubtract)
return date >= startDate
})
return (
<Card className="@container/card">
<CardHeader>
<CardTitle>Total Visitors</CardTitle>
<CardDescription>
<span className="hidden @[540px]/card:block">
Total for the last 3 months
</span>
<span className="@[540px]/card:hidden">Last 3 months</span>
</CardDescription>
<CardAction>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:px-4! @[767px]/card:flex"
>
<ToggleGroupItem value="90d">Last 3 months</ToggleGroupItem>
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
</ToggleGroup>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
size="sm"
aria-label="Select a value"
>
<SelectValue placeholder="Last 3 months" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="90d" className="rounded-lg">
Last 3 months
</SelectItem>
<SelectItem value="30d" className="rounded-lg">
Last 30 days
</SelectItem>
<SelectItem value="7d" className="rounded-lg">
Last 7 days
</SelectItem>
</SelectContent>
</Select>
</CardAction>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={filteredData}>
<defs>
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-desktop)"
stopOpacity={1.0}
/>
<stop
offset="95%"
stopColor="var(--color-desktop)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-mobile)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-mobile)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value)
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(value) => {
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}}
indicator="dot"
/>
}
/>
<Area
dataKey="mobile"
type="natural"
fill="url(#fillMobile)"
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="url(#fillDesktop)"
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)
}
@@ -1,80 +1,608 @@
import {FareCalculatorCard} from "#/components/dashboard/FareCalculatorCard";
import {InventoryView} from "#/components/dashboard/InventoryView";
import {OrderHistoryView} from "#/components/dashboard/OrderHistoryView";
import {cn} from "#/lib/utils";
import {Boxes, ClipboardList} from "lucide-react";
import {useState} from "react";
import {ProductGrid} from "#/components/store/ProductGrid";
import {QuantityStepper} from "#/components/store/QuantityStepper";
import {StoreHome} from "#/components/store/StoreHome";
import {AppSidebar} from "#/components/app-sidebar";
import {SiteHeader} from "#/components/site-header";
import {ErrorState} from "#/components/ui/ErrorState";
import {LoadingState} from "#/components/ui/LoadingState";
import {Badge} from "#/components/ui/badge";
import {Button} from "#/components/ui/button";
import {Card, CardContent, CardHeader, CardTitle} from "#/components/ui/card";
import {Input} from "#/components/ui/input";
import {Label} from "#/components/ui/label";
import {Separator} from "#/components/ui/separator";
import {SidebarInset, SidebarProvider} from "#/components/ui/sidebar";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "#/components/ui/table";
import {formatCurrency} from "#/hooks/formatCurrency";
import {formatDate} from "#/hooks/formatDate";
import {useAuth} from "#/hooks/useAuth";
import {useCart} from "#/hooks/useCart";
import {useOrders, useUpdateOrderStatus} from "#/hooks/useOrders";
import {
useCreateProduct,
useDeactivateProduct,
useProducts,
useUpdateProduct,
} from "#/hooks/useProducts";
import {
useAcceptRide,
useCancelRide,
useCompleteRide,
useRequestRide,
useRides,
useUpdateRideStatus,
} from "#/hooks/useRides";
import type {OrderStatus} from "#/types/order";
import type {Product, ProductRequest} from "#/types/product";
import type {Ride, RideStatus} from "#/types/ride";
import {Link} from "@tanstack/react-router";
import {Boxes, Car, ClipboardList, PackagePlus, ShoppingCart} from "lucide-react";
import {useState, type CSSProperties, type FormEvent, type ReactNode} from "react";
import {toast} from "sonner";
type Tab = "inventory" | "history";
const emptyProductForm: ProductRequest = {
name: "",
sku: "",
price: 0,
currency: "KES",
active: true,
availableQuantity: 0,
reorderLevel: 5,
};
export function Dashboard() {
const [activeTab, setActiveTab] = useState<Tab>("inventory");
const {user, logout} = useAuth();
if (!user) {
return <StoreHome/>;
}
return (
<main className="min-h-screen bg-slate-100">
<div className="mx-auto max-w-7xl px-4 py-8">
<header className="mb-6">
<p className="text-sm font-medium uppercase tracking-wide text-slate-500">
Ops Dashboard
</p>
<h1 className="mt-1 text-3xl font-bold text-slate-950">
Order Management + Fare Engine
</h1>
<p className="mt-2 max-w-2xl text-slate-600">
Monitor stock levels, review order history, cancel orders with stock
rollback, and calculate trip fares.
</p>
</header>
<div className="mb-6">
<FareCalculatorCard/>
</div>
<div className="mb-4 flex rounded-xl border border-slate-200 bg-white p-1 shadow-sm">
<TabButton
active={activeTab === "inventory"}
onClick={() => setActiveTab("inventory")}
icon={<Boxes size={16}/>}
>
Inventory
</TabButton>
<TabButton
active={activeTab === "history"}
onClick={() => setActiveTab("history")}
icon={<ClipboardList size={16}/>}
>
Trip / Order History
</TabButton>
</div>
{activeTab === "inventory" ? <InventoryView/> : <OrderHistoryView/>}
</div>
</main>
<SidebarProvider
style={{
"--sidebar-width": "18rem",
"--header-height": "3.5rem",
} as CSSProperties}
>
<AppSidebar user={user} onLogout={logout} variant="inset"/>
<SidebarInset>
<SiteHeader
title={dashboardTitle(user.role)}
onLogout={logout}
showCart={user.role === "CUSTOMER"}
/>
<main className="@container/main flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">
{user.role === "CUSTOMER" ? <CustomerDashboard/> : null}
{user.role === "ADMIN" ? <AdminDashboard/> : null}
{user.role === "DRIVER" ? <DriverDashboard/> : null}
</main>
</SidebarInset>
</SidebarProvider>
);
}
type TabButtonProps = {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
children: React.ReactNode;
function dashboardTitle(role: "ADMIN" | "CUSTOMER" | "DRIVER") {
if (role === "ADMIN") {
return "Admin dashboard";
}
if (role === "DRIVER") {
return "Driver dashboard";
}
return "Customer dashboard";
}
function CustomerDashboard() {
const productsQuery = useProducts();
const ordersQuery = useOrders({status: "", from: "", to: ""});
const ridesQuery = useRides();
const requestRide = useRequestRide();
const cancelRide = useCancelRide();
const cart = useCart();
const [rideForm, setRideForm] = useState({pickupLocation: "", dropoffLocation: "", distanceKm: "5"});
async function submitRide(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
await requestRide.mutateAsync({
pickupLocation: rideForm.pickupLocation,
dropoffLocation: rideForm.dropoffLocation,
distanceKm: Number(rideForm.distanceKm),
});
toast.success("Ride requested");
setRideForm({pickupLocation: "", dropoffLocation: "", distanceKm: "5"});
}
if (productsQuery.isError) {
return <ErrorState message={errorMessage(productsQuery.error)} onRetry={() => productsQuery.refetch()}/>;
}
return (
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_360px]">
<section className="space-y-5">
<Panel title="Products" icon={<Boxes/>}>
<ProductGrid
products={productsQuery.data ?? []}
isLoading={productsQuery.isLoading}
onAddToCart={cart.addItem}
/>
</Panel>
<Panel title="My orders" icon={<ClipboardList/>}>
<OrderTable
orders={ordersQuery.data?.items ?? []}
action={(order) => <OrderStatusBadge status={order.status}/>}
/>
</Panel>
<Panel title="My rides" icon={<Car/>}>
<RideRequestForm
form={rideForm}
setForm={setRideForm}
onSubmit={submitRide}
pending={requestRide.isPending}
/>
<RideTable
rides={ridesQuery.data ?? []}
action={(ride) =>
ride.status === "REQUESTED" ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => cancelRide.mutate(ride.id, {onSuccess: () => toast.success("Ride cancelled")})}
>
Cancel
</Button>
) : null
}
/>
</Panel>
</section>
<aside className="space-y-5">
<Panel title="Cart" icon={<ShoppingCart/>}>
{cart.items.length === 0 ? (
<p className="text-sm text-muted-foreground">Your cart is empty.</p>
) : (
<div className="space-y-4">
{cart.items.map((item) => (
<div key={item.product.id} className="space-y-3 rounded-lg border border-border p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-medium">{item.product.name}</p>
<p className="text-sm text-muted-foreground">
{formatCurrency(item.product.price, item.product.currency)} each
</p>
</div>
<p className="font-semibold">
{formatCurrency(item.product.price * item.quantity, item.product.currency)}
</p>
</div>
<QuantityStepper
value={item.quantity}
min={0}
max={item.product.availableQuantity}
onChange={(quantity) => cart.updateQuantity(item.product.id, quantity)}
/>
</div>
))}
<Separator/>
<div className="flex justify-between font-semibold">
<span>Total</span>
<span>{formatCurrency(cart.totalAmount, cart.currency)}</span>
</div>
<Button asChild className="w-full">
<Link to="/cart">View cart</Link>
</Button>
</div>
)}
</Panel>
</aside>
</div>
);
}
function AdminDashboard() {
const productsQuery = useProducts();
const ordersQuery = useOrders({status: "", from: "", to: ""});
const ridesQuery = useRides();
const createProduct = useCreateProduct();
const updateProduct = useUpdateProduct();
const deactivateProduct = useDeactivateProduct();
const updateOrderStatus = useUpdateOrderStatus();
const updateRideStatus = useUpdateRideStatus();
const [editing, setEditing] = useState<Product | null>(null);
const [form, setForm] = useState<ProductRequest>(emptyProductForm);
function startEdit(product: Product) {
setEditing(product);
setForm({
name: product.name,
sku: product.sku,
price: product.price,
currency: product.currency,
active: product.active,
availableQuantity: product.availableQuantity,
reorderLevel: product.reorderLevel,
});
}
async function submitProduct(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (editing) {
await updateProduct.mutateAsync({id: editing.id, request: form});
toast.success("Product updated");
} else {
await createProduct.mutateAsync(form);
toast.success("Product created");
}
setEditing(null);
setForm(emptyProductForm);
}
return (
<div className="space-y-5">
<Panel title={editing ? "Edit product" : "Add product"} icon={<PackagePlus/>}>
<ProductForm
form={form}
setForm={setForm}
onSubmit={submitProduct}
pending={createProduct.isPending || updateProduct.isPending}
/>
</Panel>
<Panel title="Products" icon={<Boxes/>}>
<ProductAdminTable
products={productsQuery.data ?? []}
onEdit={startEdit}
onDeactivate={(id) =>
deactivateProduct.mutate(id, {onSuccess: () => toast.success("Product deactivated")})
}
/>
</Panel>
<Panel title="Orders" icon={<ClipboardList/>}>
<OrderTable
orders={ordersQuery.data?.items ?? []}
action={(order) => (
<StatusSelect<OrderStatus>
value={order.status}
options={["PENDING", "SHIPPED", "DELIVERED", "CANCELLED"]}
onChange={(status) =>
updateOrderStatus.mutate({id: order.id, status}, {onSuccess: () => toast.success("Order updated")})
}
/>
)}
/>
</Panel>
<Panel title="Rides" icon={<Car/>}>
<RideTable
rides={ridesQuery.data ?? []}
action={(ride) => (
<StatusSelect<RideStatus>
value={ride.status}
options={["REQUESTED", "ACCEPTED", "COMPLETED", "CANCELLED"]}
onChange={(status) =>
updateRideStatus.mutate({id: ride.id, status}, {onSuccess: () => toast.success("Ride updated")})
}
/>
)}
/>
</Panel>
</div>
);
}
function DriverDashboard() {
const ridesQuery = useRides();
const acceptRide = useAcceptRide();
const completeRide = useCompleteRide();
return (
<Panel title="Driver rides" icon={<Car/>}>
{ridesQuery.isLoading ? <LoadingState title="Loading rides"/> : null}
<RideTable
rides={ridesQuery.data ?? []}
action={(ride) => {
if (ride.status === "REQUESTED") {
return (
<Button
type="button"
size="sm"
onClick={() => acceptRide.mutate(ride.id, {onSuccess: () => toast.success("Ride accepted")})}
>
Accept
</Button>
);
}
if (ride.status === "ACCEPTED") {
return (
<Button
type="button"
size="sm"
onClick={() => completeRide.mutate(ride.id, {onSuccess: () => toast.success("Ride completed")})}
>
Complete
</Button>
);
}
return null;
}}
/>
</Panel>
);
}
function Panel({title, icon, children}: {title: string; icon: ReactNode; children: ReactNode}) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-muted-foreground [&_svg]:size-5">{icon}</span>
{title}
</CardTitle>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
function ProductForm({
form,
setForm,
onSubmit,
pending,
}: {
form: ProductRequest;
setForm: (form: ProductRequest) => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
pending: boolean;
}) {
return (
<form onSubmit={onSubmit} className="grid gap-4 md:grid-cols-4">
<Field label="Name" value={form.name} onChange={(value) => setForm({...form, name: value})}/>
<Field label="SKU" value={form.sku} onChange={(value) => setForm({...form, sku: value})}/>
<NumberField label="Price" value={form.price} onChange={(value) => setForm({...form, price: value})}/>
<Field label="Currency" value={form.currency} onChange={(value) => setForm({...form, currency: value})}/>
<NumberField label="Available" value={form.availableQuantity} onChange={(value) => setForm({...form, availableQuantity: value})}/>
<NumberField label="Reorder level" value={form.reorderLevel} onChange={(value) => setForm({...form, reorderLevel: value})}/>
<label className="flex items-end gap-2 pb-2 text-sm">
<input
type="checkbox"
checked={form.active}
onChange={(event) => setForm({...form, active: event.target.checked})}
/>
Active
</label>
<div className="flex items-end">
<Button type="submit" disabled={pending} className="w-full">
{pending ? "Saving..." : "Save product"}
</Button>
</div>
</form>
);
}
function ProductAdminTable({
products,
onEdit,
onDeactivate,
}: {
products: Product[];
onEdit: (product: Product) => void;
onDeactivate: (id: string) => void;
}) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead>Price</TableHead>
<TableHead>Stock</TableHead>
<TableHead>Status</TableHead>
<TableHead>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.map((product) => (
<TableRow key={product.id}>
<TableCell>
<p className="font-medium">{product.name}</p>
<p className="text-xs text-muted-foreground">{product.sku}</p>
</TableCell>
<TableCell>{formatCurrency(product.price, product.currency)}</TableCell>
<TableCell>{product.availableQuantity}</TableCell>
<TableCell><Badge variant={product.active ? "secondary" : "outline"}>{product.active ? "Active" : "Inactive"}</Badge></TableCell>
<TableCell>
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => onEdit(product)}>Edit</Button>
<Button type="button" variant="destructive" size="sm" onClick={() => onDeactivate(product.id)}>Deactivate</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
type OrderRow = {
id: string;
customerName: string;
status: OrderStatus;
itemCount: number;
totalAmount: number;
currency: string;
createdAt: string;
};
function TabButton({active, onClick, icon, children}: TabButtonProps) {
function OrderTable({
orders,
action,
}: {
orders: OrderRow[];
action: (order: Pick<OrderRow, "id" | "status">) => ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition",
active
? "bg-slate-900 text-white shadow-sm"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900",
)}
>
{icon}
{children}
</button>
<Table>
<TableHeader>
<TableRow>
<TableHead>Order</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Status</TableHead>
<TableHead>Items</TableHead>
<TableHead>Total</TableHead>
<TableHead>Created</TableHead>
<TableHead>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orders.map((order) => (
<TableRow key={order.id}>
<TableCell className="font-mono text-xs">{order.id.slice(0, 8)}</TableCell>
<TableCell>{order.customerName}</TableCell>
<TableCell><OrderStatusBadge status={order.status}/></TableCell>
<TableCell>{order.itemCount}</TableCell>
<TableCell>{formatCurrency(order.totalAmount, order.currency)}</TableCell>
<TableCell>{formatDate(order.createdAt)}</TableCell>
<TableCell>{action(order)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
}
function RideRequestForm({
form,
setForm,
onSubmit,
pending,
}: {
form: {pickupLocation: string; dropoffLocation: string; distanceKm: string};
setForm: (form: {pickupLocation: string; dropoffLocation: string; distanceKm: string}) => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
pending: boolean;
}) {
return (
<form onSubmit={onSubmit} className="mb-4 grid gap-4 md:grid-cols-4">
<Field label="Pickup" value={form.pickupLocation} onChange={(value) => setForm({...form, pickupLocation: value})}/>
<Field label="Dropoff" value={form.dropoffLocation} onChange={(value) => setForm({...form, dropoffLocation: value})}/>
<Field label="Distance KM" type="number" value={form.distanceKm} onChange={(value) => setForm({...form, distanceKm: value})}/>
<div className="flex items-end">
<Button type="submit" disabled={pending} className="w-full">
{pending ? "Requesting..." : "Request ride"}
</Button>
</div>
</form>
);
}
function RideTable({
rides,
action,
}: {
rides: Ride[];
action: (ride: Ride) => ReactNode;
}) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Ride</TableHead>
<TableHead>Route</TableHead>
<TableHead>Status</TableHead>
<TableHead>Driver</TableHead>
<TableHead>Fare</TableHead>
<TableHead>Requested</TableHead>
<TableHead>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rides.map((ride) => (
<TableRow key={ride.id}>
<TableCell className="font-mono text-xs">{ride.id.slice(0, 8)}</TableCell>
<TableCell>{ride.pickupLocation} to {ride.dropoffLocation}</TableCell>
<TableCell><RideStatusBadge status={ride.status}/></TableCell>
<TableCell>{ride.driverName ?? "-"}</TableCell>
<TableCell>{ride.fareAmount ? formatCurrency(ride.fareAmount, ride.currency) : "-"}</TableCell>
<TableCell>{formatDate(ride.requestedAt)}</TableCell>
<TableCell>{action(ride)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
function StatusSelect<T extends string>({
value,
options,
onChange,
}: {
value: T;
options: T[];
onChange: (value: T) => void;
}) {
return (
<select
value={value}
onChange={(event) => onChange(event.target.value as T)}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
>
{options.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
);
}
function OrderStatusBadge({status}: {status: OrderStatus}) {
return <Badge variant={status === "CANCELLED" ? "outline" : "secondary"}>{status}</Badge>;
}
function RideStatusBadge({status}: {status: RideStatus}) {
return <Badge variant={status === "CANCELLED" ? "outline" : "secondary"}>{status}</Badge>;
}
function Field({
label,
value,
onChange,
type = "text",
}: {
label: string;
value: string;
onChange: (value: string) => void;
type?: string;
}) {
const id = label.toLowerCase().replace(/\s+/g, "-");
return (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
type={type}
value={value}
onChange={(event) => onChange(event.target.value)}
required
/>
</div>
);
}
function NumberField({label, value, onChange}: {label: string; value: number; onChange: (value: number) => void}) {
return <Field label={label} type="number" value={String(value)} onChange={(value) => onChange(Number(value))}/>;
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "Request failed";
}
@@ -1,154 +0,0 @@
import {useFareCalculation} from "#/hooks/useFareCalculation";
import {formatCurrency} from "#/hooks/formatCurrency";
import {Calculator} from "lucide-react";
import {useState} from "react";
export function FareCalculatorCard() {
const [distanceKm, setDistanceKm] = useState("10");
const [surgeMultiplier, setSurgeMultiplier] = useState("1.0");
const [validationError, setValidationError] = useState<string | null>(null);
const fareMutation = useFareCalculation();
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const distance = Number(distanceKm);
const surge = surgeMultiplier ? Number(surgeMultiplier) : undefined;
if (Number.isNaN(distance) || distance <= 0) {
setValidationError("Distance must be greater than 0.");
return;
}
if (surge !== undefined && (Number.isNaN(surge) || surge < 1)) {
setValidationError("Surge multiplier must be at least 1.0.");
return;
}
setValidationError(null);
fareMutation.mutate({
distanceKm: distance,
surgeMultiplier: surge,
});
}
const data = fareMutation.data;
return (
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<div className="flex items-center gap-2">
<div className="rounded-lg bg-slate-900 p-2 text-white">
<Calculator size={18}/>
</div>
<div>
<h2 className="font-semibold text-slate-900">Fare calculator</h2>
<p className="text-sm text-slate-500">
Base fare + distance rate, with surge and minimum fare applied.
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="mt-4 grid gap-3 md:grid-cols-3">
<label className="space-y-1">
<span className="text-xs font-medium text-slate-600">
Distance KM
</span>
<input
type="number"
min="0"
step="0.1"
value={distanceKm}
onChange={(event) => setDistanceKm(event.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-slate-600">
Surge multiplier
</span>
<input
type="number"
min="1"
step="0.1"
value={surgeMultiplier}
onChange={(event) => setSurgeMultiplier(event.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
</label>
<div className="flex items-end">
<button
type="submit"
disabled={fareMutation.isPending}
className="w-full rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{fareMutation.isPending ? "Calculating..." : "Calculate fare"}
</button>
</div>
</form>
{validationError ? (
<p className="mt-4 rounded-lg bg-amber-50 p-3 text-sm text-amber-700">
{validationError}
</p>
) : null}
{fareMutation.isError ? (
<p className="mt-4 rounded-lg bg-red-50 p-3 text-sm text-red-700">
{fareMutation.error instanceof Error
? fareMutation.error.message
: "Failed to calculate fare"}
</p>
) : null}
{data ? (
<div className="mt-4 grid gap-3 rounded-xl bg-slate-50 p-4 md:grid-cols-5">
<FareMetric
label="Base"
value={formatCurrency(data.baseFare, data.currency)}
/>
<FareMetric
label="Per KM"
value={formatCurrency(data.perKmRate, data.currency)}
/>
<FareMetric label="Surge" value={`${data.surgeMultiplier}x`}/>
<FareMetric
label="Minimum"
value={formatCurrency(data.minimumFare, data.currency)}
/>
<FareMetric
label="Total"
value={formatCurrency(data.calculatedFare, data.currency)}
strong
/>
</div>
) : null}
</section>
);
}
type FareMetricProps = {
label: string;
value: string;
strong?: boolean;
};
function FareMetric({label, value, strong = false}: FareMetricProps) {
return (
<div>
<p className="text-xs text-slate-500">{label}</p>
<p
className={
strong
? "mt-1 text-lg font-bold text-slate-900"
: "mt-1 font-semibold text-slate-800"
}
>
{value}
</p>
</div>
);
}
@@ -1,81 +0,0 @@
import {InventoryTable} from "#/components/inventory/InventoryTable";
import {EmptyState} from "#/components/ui/EmptyState";
import {LoadingState} from "#/components/ui/LoadingState";
import {useInventory} from "#/hooks/useInventory";
import {ErrorState} from "#/components/ui/ErrorState.tsx";
export function InventoryView() {
const inventoryQuery = useInventory();
if (inventoryQuery.isLoading) {
return (
<LoadingState
title="Loading inventory"
description="Fetching current stock levels."
/>
);
}
if (inventoryQuery.isError) {
return (
<ErrorState
title="Could not load inventory"
message={
inventoryQuery.error instanceof Error
? inventoryQuery.error.message
: "Failed to load inventory"
}
onRetry={() => inventoryQuery.refetch()}
/>
);
}
const items = inventoryQuery.data?.items ?? [];
if (items.length === 0) {
return (
<EmptyState
title="No inventory found"
description="Inventory records will appear here once products are seeded or added."
/>
);
}
const lowStockCount = items.filter((item) => item.status === "LOW_STOCK").length;
const outOfStockCount = items.filter(
(item) => item.status === "OUT_OF_STOCK",
).length;
return (
<section className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<p className="text-sm text-slate-500">Total products</p>
<p className="mt-2 text-2xl font-semibold text-slate-900">
{items.length}
</p>
</div>
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4">
<p className="text-sm text-amber-700">Low stock</p>
<p className="mt-2 text-2xl font-semibold text-amber-900">
{lowStockCount}
</p>
</div>
<div className="rounded-xl border border-red-200 bg-red-50 p-4">
<p className="text-sm text-red-700">Out of stock</p>
<p className="mt-2 text-2xl font-semibold text-red-900">
{outOfStockCount}
</p>
</div>
</div>
{inventoryQuery.isFetching ? (
<p className="text-sm text-slate-500">Refreshing inventory...</p>
) : null}
<InventoryTable items={items}/>
</section>
);
}
@@ -1,70 +0,0 @@
import {OrderFilters} from "#/components/orders/OrderFilters";
import {OrderHistoryTable} from "#/components/orders/OrderHistoryTable";
import {EmptyState} from "#/components/ui/EmptyState";
import {ErrorState} from "#/components/ui/ErrorState";
import {LoadingState} from "#/components/ui/LoadingState";
import {useOrders} from "#/hooks/useOrders";
import type {OrderFilters as OrderFiltersType} from "#/types/order";
import {useState} from "react";
export function OrderHistoryView() {
const [filters, setFilters] = useState<OrderFiltersType>({
status: "",
from: "",
to: "",
});
const ordersQuery = useOrders(filters);
return (
<section className="space-y-4">
<OrderFilters filters={filters} onChange={setFilters}/>
{ordersQuery.isLoading ? (
<LoadingState
title="Loading order history"
description="Fetching orders matching your filters."
/>
) : null}
{ordersQuery.isError ? (
<ErrorState
title="Could not load order history"
message={
ordersQuery.error instanceof Error
? ordersQuery.error.message
: "Failed to load order history"
}
onRetry={() => ordersQuery.refetch()}
/>
) : null}
{!ordersQuery.isLoading &&
!ordersQuery.isError &&
ordersQuery.data?.items.length === 0 ? (
<EmptyState
title="No orders found"
description="Try changing the status or date range filters."
/>
) : null}
{!ordersQuery.isLoading &&
!ordersQuery.isError &&
ordersQuery.data &&
ordersQuery.data.items.length > 0 ? (
<>
{ordersQuery.isFetching ? (
<p className="text-sm text-slate-500">Refreshing order history...</p>
) : null}
<OrderHistoryTable orders={ordersQuery.data.items}/>
<p className="text-sm text-slate-500">
Showing {ordersQuery.data.items.length} of{" "}
{ordersQuery.data.totalItems} orders.
</p>
</>
) : null}
</section>
);
}
@@ -0,0 +1,805 @@
import * as React from "react"
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
type UniqueIdentifier,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronsLeft,
IconChevronsRight,
IconCircleCheckFilled,
IconDotsVertical,
IconGripVertical,
IconLayoutColumns,
IconLoader,
IconPlus,
IconTrendingUp,
} from "@tabler/icons-react"
import {
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type ColumnFiltersState,
type Row,
type SortingState,
type VisibilityState,
} from "@tanstack/react-table"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import { toast } from "sonner"
import { z } from "zod"
import { useIsMobile } from "#/hooks/use-mobile.ts"
import { Badge } from "#/components/ui/badge.tsx"
import { Button } from "#/components/ui/button.tsx"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "#/components/ui/chart.tsx"
import { Checkbox } from "#/components/ui/checkbox.tsx"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "#/components/ui/drawer.tsx"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "#/components/ui/dropdown-menu.tsx"
import { Input } from "#/components/ui/input.tsx"
import { Label } from "#/components/ui/label.tsx"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "#/components/ui/select.tsx"
import { Separator } from "#/components/ui/separator.tsx"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "#/components/ui/table.tsx"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "#/components/ui/tabs.tsx"
export const schema = z.object({
id: z.number(),
header: z.string(),
type: z.string(),
status: z.string(),
target: z.string(),
limit: z.string(),
reviewer: z.string(),
})
// Create a separate component for the drag handle
function DragHandle({ id }: { id: number }) {
const { attributes, listeners } = useSortable({
id,
})
return (
<Button
{...attributes}
{...listeners}
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:bg-transparent"
>
<IconGripVertical className="size-3 text-muted-foreground" />
<span className="sr-only">Drag to reorder</span>
</Button>
)
}
const columns: ColumnDef<z.infer<typeof schema>>[] = [
{
id: "drag",
header: () => null,
cell: ({ row }) => <DragHandle id={row.original.id} />,
},
{
id: "select",
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "header",
header: "Header",
cell: ({ row }) => {
return <TableCellViewer item={row.original} />
},
enableHiding: false,
},
{
accessorKey: "type",
header: "Section Type",
cell: ({ row }) => (
<div className="w-32">
<Badge variant="outline" className="px-1.5 text-muted-foreground">
{row.original.type}
</Badge>
</div>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => (
<Badge variant="outline" className="px-1.5 text-muted-foreground">
{row.original.status === "Done" ? (
<IconCircleCheckFilled className="fill-green-500 dark:fill-green-400" />
) : (
<IconLoader />
)}
{row.original.status}
</Badge>
),
},
{
accessorKey: "target",
header: () => <div className="w-full text-right">Target</div>,
cell: ({ row }) => (
<form
onSubmit={(e) => {
e.preventDefault()
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
loading: `Saving ${row.original.header}`,
success: "Done",
error: "Error",
})
}}
>
<Label htmlFor={`${row.original.id}-target`} className="sr-only">
Target
</Label>
<Input
className="h-8 w-16 border-transparent bg-transparent text-right shadow-none hover:bg-input/30 focus-visible:border focus-visible:bg-background dark:bg-transparent dark:hover:bg-input/30 dark:focus-visible:bg-input/30"
defaultValue={row.original.target}
id={`${row.original.id}-target`}
/>
</form>
),
},
{
accessorKey: "limit",
header: () => <div className="w-full text-right">Limit</div>,
cell: ({ row }) => (
<form
onSubmit={(e) => {
e.preventDefault()
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
loading: `Saving ${row.original.header}`,
success: "Done",
error: "Error",
})
}}
>
<Label htmlFor={`${row.original.id}-limit`} className="sr-only">
Limit
</Label>
<Input
className="h-8 w-16 border-transparent bg-transparent text-right shadow-none hover:bg-input/30 focus-visible:border focus-visible:bg-background dark:bg-transparent dark:hover:bg-input/30 dark:focus-visible:bg-input/30"
defaultValue={row.original.limit}
id={`${row.original.id}-limit`}
/>
</form>
),
},
{
accessorKey: "reviewer",
header: "Reviewer",
cell: ({ row }) => {
const isAssigned = row.original.reviewer !== "Assign reviewer"
if (isAssigned) {
return row.original.reviewer
}
return (
<>
<Label htmlFor={`${row.original.id}-reviewer`} className="sr-only">
Reviewer
</Label>
<Select>
<SelectTrigger
className="w-38 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate"
size="sm"
id={`${row.original.id}-reviewer`}
>
<SelectValue placeholder="Assign reviewer" />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
<SelectItem value="Jamik Tashpulatov">
Jamik Tashpulatov
</SelectItem>
</SelectContent>
</Select>
</>
)
},
},
{
id: "actions",
cell: () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex size-8 text-muted-foreground data-[state=open]:bg-muted"
size="icon"
>
<IconDotsVertical />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Make a copy</DropdownMenuItem>
<DropdownMenuItem>Favorite</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
]
function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
const { transform, transition, setNodeRef, isDragging } = useSortable({
id: row.original.id,
})
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
data-dragging={isDragging}
ref={setNodeRef}
className="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
style={{
transform: CSS.Transform.toString(transform),
transition: transition,
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
export function DataTable({
data: initialData,
}: {
data: z.infer<typeof schema>[]
}) {
const [data, setData] = React.useState(() => initialData)
const [rowSelection, setRowSelection] = React.useState({})
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
)
const [sorting, setSorting] = React.useState<SortingState>([])
const [pagination, setPagination] = React.useState({
pageIndex: 0,
pageSize: 10,
})
const sortableId = React.useId()
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
const dataIds = React.useMemo<UniqueIdentifier[]>(
() => data?.map(({ id }) => id) || [],
[data]
)
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
pagination,
},
getRowId: (row) => row.id.toString(),
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
})
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (active && over && active.id !== over.id) {
setData((data) => {
const oldIndex = dataIds.indexOf(active.id)
const newIndex = dataIds.indexOf(over.id)
return arrayMove(data, oldIndex, newIndex)
})
}
}
return (
<Tabs
defaultValue="outline"
className="w-full flex-col justify-start gap-6"
>
<div className="flex items-center justify-between px-4 lg:px-6">
<Label htmlFor="view-selector" className="sr-only">
View
</Label>
<Select defaultValue="outline">
<SelectTrigger
className="flex w-fit @4xl/main:hidden"
size="sm"
id="view-selector"
>
<SelectValue placeholder="Select a view" />
</SelectTrigger>
<SelectContent>
<SelectItem value="outline">Outline</SelectItem>
<SelectItem value="past-performance">Past Performance</SelectItem>
<SelectItem value="key-personnel">Key Personnel</SelectItem>
<SelectItem value="focus-documents">Focus Documents</SelectItem>
</SelectContent>
</Select>
<TabsList className="hidden **:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:bg-muted-foreground/30 **:data-[slot=badge]:px-1 @4xl/main:flex">
<TabsTrigger value="outline">Outline</TabsTrigger>
<TabsTrigger value="past-performance">
Past Performance <Badge variant="secondary">3</Badge>
</TabsTrigger>
<TabsTrigger value="key-personnel">
Key Personnel <Badge variant="secondary">2</Badge>
</TabsTrigger>
<TabsTrigger value="focus-documents">Focus Documents</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconLayoutColumns />
<span className="hidden lg:inline">Customize Columns</span>
<span className="lg:hidden">Columns</span>
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" &&
column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm">
<IconPlus />
<span className="hidden lg:inline">Add Section</span>
</Button>
</div>
</div>
<TabsContent
value="outline"
className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6"
>
<div className="overflow-hidden rounded-lg border">
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
sensors={sensors}
id={sortableId}
>
<Table>
<TableHeader className="sticky top-0 z-10 bg-muted">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody className="**:data-[slot=table-cell]:first:w-8">
{table.getRowModel().rows?.length ? (
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row) => (
<DraggableRow key={row.id} row={row} />
))}
</SortableContext>
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DndContext>
</div>
<div className="flex items-center justify-between px-4">
<div className="hidden flex-1 text-sm text-muted-foreground lg:flex">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
Rows per page
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<IconChevronsLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<IconChevronLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<IconChevronRight />
</Button>
<Button
variant="outline"
className="hidden size-8 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<IconChevronsRight />
</Button>
</div>
</div>
</div>
</TabsContent>
<TabsContent
value="past-performance"
className="flex flex-col px-4 lg:px-6"
>
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
<TabsContent value="key-personnel" className="flex flex-col px-4 lg:px-6">
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
<TabsContent
value="focus-documents"
className="flex flex-col px-4 lg:px-6"
>
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
</Tabs>
)
}
const chartData = [
{ month: "January", desktop: 186, mobile: 80 },
{ month: "February", desktop: 305, mobile: 200 },
{ month: "March", desktop: 237, mobile: 120 },
{ month: "April", desktop: 73, mobile: 190 },
{ month: "May", desktop: 209, mobile: 130 },
{ month: "June", desktop: 214, mobile: 140 },
]
const chartConfig = {
desktop: {
label: "Desktop",
color: "var(--primary)",
},
mobile: {
label: "Mobile",
color: "var(--primary)",
},
} satisfies ChartConfig
function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
const isMobile = useIsMobile()
return (
<Drawer direction={isMobile ? "bottom" : "right"}>
<DrawerTrigger asChild>
<Button variant="link" className="w-fit px-0 text-left text-foreground">
{item.header}
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="gap-1">
<DrawerTitle>{item.header}</DrawerTitle>
<DrawerDescription>
Showing total visitors for the last 6 months
</DrawerDescription>
</DrawerHeader>
<div className="flex flex-col gap-4 overflow-y-auto px-4 text-sm">
{!isMobile && (
<>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={chartData}
margin={{
left: 0,
right: 10,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => value.slice(0, 3)}
hide
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
<Area
dataKey="mobile"
type="natural"
fill="var(--color-mobile)"
fillOpacity={0.6}
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="var(--color-desktop)"
fillOpacity={0.4}
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
<Separator />
<div className="grid gap-2">
<div className="flex gap-2 leading-none font-medium">
Trending up by 5.2% this month{" "}
<IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">
Showing total visitors for the last 6 months. This is just
some random text to test the layout. It spans multiple lines
and should wrap around.
</div>
</div>
<Separator />
</>
)}
<form className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="header">Header</Label>
<Input id="header" defaultValue={item.header} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="type">Type</Label>
<Select defaultValue={item.type}>
<SelectTrigger id="type" className="w-full">
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Table of Contents">
Table of Contents
</SelectItem>
<SelectItem value="Executive Summary">
Executive Summary
</SelectItem>
<SelectItem value="Technical Approach">
Technical Approach
</SelectItem>
<SelectItem value="Design">Design</SelectItem>
<SelectItem value="Capabilities">Capabilities</SelectItem>
<SelectItem value="Focus Documents">
Focus Documents
</SelectItem>
<SelectItem value="Narrative">Narrative</SelectItem>
<SelectItem value="Cover Page">Cover Page</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="status">Status</Label>
<Select defaultValue={item.status}>
<SelectTrigger id="status" className="w-full">
<SelectValue placeholder="Select a status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Done">Done</SelectItem>
<SelectItem value="In Progress">In Progress</SelectItem>
<SelectItem value="Not Started">Not Started</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="target">Target</Label>
<Input id="target" defaultValue={item.target} />
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="limit">Limit</Label>
<Input id="limit" defaultValue={item.limit} />
</div>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="reviewer">Reviewer</Label>
<Select defaultValue={item.reviewer}>
<SelectTrigger id="reviewer" className="w-full">
<SelectValue placeholder="Select a reviewer" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
<SelectItem value="Jamik Tashpulatov">
Jamik Tashpulatov
</SelectItem>
<SelectItem value="Emily Whalen">Emily Whalen</SelectItem>
</SelectContent>
</Select>
</div>
</form>
</div>
<DrawerFooter>
<Button>Submit</Button>
<DrawerClose asChild>
<Button variant="outline">Done</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
@@ -1,27 +0,0 @@
import {cn} from "#/lib/utils";
import type {InventoryStatus} from "#/types/inventory";
type Props = {
status: InventoryStatus;
};
const labelMap: Record<InventoryStatus, string> = {
IN_STOCK: "In stock",
LOW_STOCK: "Low stock",
OUT_OF_STOCK: "Out of stock",
};
export function InventoryStatusBadge({status}: Props) {
return (
<span
className={cn(
"inline-flex rounded-full px-2.5 py-1 text-xs font-medium",
status === "IN_STOCK" && "bg-emerald-50 text-emerald-700",
status === "LOW_STOCK" && "bg-amber-50 text-amber-700",
status === "OUT_OF_STOCK" && "bg-red-50 text-red-700",
)}
>
{labelMap[status]}
</span>
);
}
@@ -1,53 +0,0 @@
import {InventoryStatusBadge} from "#/components/inventory/InventoryStatusBadge";
import type {InventoryItem} from "#/types/inventory";
import {formatDate} from "#/hooks/formatDate";
type Props = {
items: InventoryItem[];
};
export function InventoryTable({items}: Props) {
return (
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<table className="w-full border-collapse text-left text-sm">
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
<tr>
<th className="px-4 py-3">Product</th>
<th className="px-4 py-3">SKU</th>
<th className="px-4 py-3">Available</th>
<th className="px-4 py-3">Reserved</th>
<th className="px-4 py-3">Reorder Level</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{items.map((item) => (
<tr key={item.productId} className="hover:bg-slate-50">
<td className="px-4 py-3 font-medium text-slate-900">
{item.productName}
</td>
<td className="px-4 py-3 text-slate-600">{item.sku}</td>
<td className="px-4 py-3 text-slate-900">
{item.availableQuantity}
</td>
<td className="px-4 py-3 text-slate-600">
{item.reservedQuantity}
</td>
<td className="px-4 py-3 text-slate-600">
{item.reorderLevel}
</td>
<td className="px-4 py-3">
<InventoryStatusBadge status={item.status}/>
</td>
<td className="px-4 py-3 text-slate-600">
{formatDate(item.updatedAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,100 @@
import { cn } from "#/lib/utils.ts"
import { Button } from "#/components/ui/button.tsx"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "#/components/ui/card.tsx"
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "#/components/ui/field.tsx"
import { Input } from "#/components/ui/input.tsx"
type LoginFormProps = React.ComponentProps<"div"> & {
username: string
password: string
error?: string | null
pending?: boolean
onUsernameChange: (value: string) => void
onPasswordChange: (value: string) => void
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void
onSignupClick: () => void
}
export function LoginForm({
className,
username,
password,
error,
pending = false,
onUsernameChange,
onPasswordChange,
onSubmit,
onSignupClick,
...props
}: LoginFormProps) {
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader>
<CardTitle>Sign in to Cymelle</CardTitle>
<CardDescription>
Use your username and password to continue.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="login-username">Username</FieldLabel>
<Input
id="login-username"
type="text"
autoComplete="username"
value={username}
onChange={(event) => onUsernameChange(event.target.value)}
required
/>
</Field>
<Field>
<div className="flex items-center">
<FieldLabel htmlFor="login-password">Password</FieldLabel>
</div>
<Input
id="login-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => onPasswordChange(event.target.value)}
required
/>
</Field>
{error ? <FieldError>{error}</FieldError> : null}
<Field>
<Button type="submit" disabled={pending}>
{pending ? "Signing in..." : "Sign in"}
</Button>
<FieldDescription className="text-center">
Don&apos;t have an account?{" "}
<button
type="button"
className="underline underline-offset-4 hover:text-primary"
onClick={onSignupClick}
>
Sign up
</button>
</FieldDescription>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,92 @@
"use client"
import {
IconDots,
IconFolder,
IconShare3,
IconTrash,
type Icon,
} from "@tabler/icons-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "#/components/ui/dropdown-menu.tsx"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "#/components/ui/sidebar.tsx"
export function NavDocuments({
items,
}: {
items: {
name: string
url: string
icon: Icon
}[]
}) {
const { isMobile } = useSidebar()
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Documents</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="rounded-sm data-[state=open]:bg-accent"
>
<IconDots />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-24 rounded-lg"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem>
<IconFolder />
<span>Open</span>
</DropdownMenuItem>
<DropdownMenuItem>
<IconShare3 />
<span>Share</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<IconTrash />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<IconDots className="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
}
+164
View File
@@ -0,0 +1,164 @@
import { ChevronRight, type LucideIcon } from "lucide-react"
import { Link, useLocation } from "@tanstack/react-router"
import { useEffect, useRef, useState } from "react"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "#/components/ui/collapsible.tsx"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "#/components/ui/sidebar.tsx"
const openSectionsStorageKey = "cymelle.sidebar.open-sections"
export function NavMain({
items,
}: {
items: {
title: string
url: string
icon?: LucideIcon
isActive?: boolean
items?: {
title: string
url: string
}[]
}[]
}) {
const location = useLocation()
const previousPath = useRef<string | null>(null)
const [openSections, setOpenSections] = useState<Set<string>>(() => {
const itemTitles = new Set(items.map((item) => item.title))
const storedSections = readOpenSections().filter((title) => itemTitles.has(title))
if (storedSections.length > 0) {
return new Set(storedSections)
}
return new Set(
items
.filter((item) => isItemActive(item, location.pathname))
.map((item) => item.title)
)
})
useEffect(() => {
writeOpenSections([...openSections])
}, [openSections])
useEffect(() => {
if (previousPath.current === location.pathname) {
return
}
previousPath.current = location.pathname
setOpenSections((current) => {
const next = new Set(current)
items
.filter((item) => isItemActive(item, location.pathname))
.forEach((item) => next.add(item.title))
return next
})
}, [items, location.pathname])
return (
<SidebarGroup>
<SidebarGroupLabel>Commerce</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<Collapsible
key={item.title}
asChild
open={openSections.has(item.title)}
onOpenChange={(open) => {
setOpenSections((current) => {
const next = new Set(current)
if (open) {
next.add(item.title)
} else {
next.delete(item.title)
}
return next
})
}}
className="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title}>
{item.icon && <item.icon />}
<span>{item.title}</span>
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton asChild>
<Link to={subItem.url}>
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
))}
</SidebarMenu>
</SidebarGroup>
)
}
function readOpenSections() {
if (typeof window === "undefined") {
return []
}
const raw = window.localStorage.getItem(openSectionsStorageKey)
if (!raw) {
return []
}
try {
const value = JSON.parse(raw)
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : []
} catch {
window.localStorage.removeItem(openSectionsStorageKey)
return []
}
}
function writeOpenSections(sections: string[]) {
if (typeof window === "undefined") {
return
}
window.localStorage.setItem(openSectionsStorageKey, JSON.stringify(sections))
}
function isItemActive(
item: {
url: string
isActive?: boolean
items?: { url: string }[]
},
pathname: string
) {
if (item.items?.some((subItem) => pathname === subItem.url)) {
return true
}
return pathname === item.url || (Boolean(item.isActive) && pathname === "/")
}
@@ -0,0 +1,79 @@
"use client"
import {
Folder,
MoreHorizontal,
type LucideIcon,
} from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "#/components/ui/dropdown-menu.tsx"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "#/components/ui/sidebar.tsx"
export function NavProjects({
projects,
}: {
projects: {
name: string
url: string
icon: LucideIcon
}[]
}) {
const { isMobile } = useSidebar()
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Shortcuts</SidebarGroupLabel>
<SidebarMenu>
{projects.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover>
<MoreHorizontal />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem asChild>
<a href={item.url}>
<Folder className="text-muted-foreground" />
<span>Open</span>
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<MoreHorizontal className="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
}
@@ -0,0 +1,42 @@
"use client"
import * as React from "react"
import { type Icon } from "@tabler/icons-react"
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "#/components/ui/sidebar.tsx"
export function NavSecondary({
items,
...props
}: {
items: {
title: string
url: string
icon: Icon
}[]
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}
+109
View File
@@ -0,0 +1,109 @@
import {
BadgeCheck,
ChevronsUpDown,
LogOut,
} from "lucide-react"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "#/components/ui/avatar.tsx"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "#/components/ui/dropdown-menu.tsx"
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "#/components/ui/sidebar.tsx"
export function NavUser({
user,
onLogout,
}: {
user: {
name: string
email: string
avatar: string
}
onLogout?: () => void
}) {
const { isMobile } = useSidebar()
const initials = user.name
.split(/\s+/)
.filter(Boolean)
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase()
|| "CY"
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<BadgeCheck />
Account session
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
void onLogout?.()
}}
>
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
@@ -1,48 +0,0 @@
import {useCancelOrder} from "#/hooks/useOrders";
import type {OrderStatus} from "#/types/order";
type Props = {
orderId: string;
status: OrderStatus;
};
export function CancelOrderButton({orderId, status}: Props) {
const cancelMutation = useCancelOrder();
if (status !== "PLACED") {
return <span className="text-sm text-slate-400">—</span>;
}
function handleCancel() {
const confirmed = window.confirm(
"Cancel this order? Stock will be rolled back.",
);
if (!confirmed) {
return;
}
cancelMutation.mutate(orderId);
}
return (
<div className="space-y-1">
<button
type="button"
disabled={cancelMutation.isPending}
onClick={handleCancel}
className="rounded-lg bg-red-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{cancelMutation.isPending ? "Cancelling..." : "Cancel"}
</button>
{cancelMutation.error ? (
<p className="text-xs text-red-600">
{cancelMutation.error instanceof Error
? cancelMutation.error.message
: "Failed to cancel order"}
</p>
) : null}
</div>
);
}
@@ -1,67 +0,0 @@
import type {OrderFilters as OrderFiltersType, OrderStatus,} from "#/types/order";
type Props = {
filters: OrderFiltersType;
onChange: (filters: OrderFiltersType) => void;
};
export function OrderFilters({filters, onChange}: Props) {
function updateFilter<Key extends keyof OrderFiltersType>(
key: Key,
value: OrderFiltersType[Key],
) {
onChange({
...filters,
[key]: value,
});
}
return (
<div className="grid gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm md:grid-cols-4">
<label className="space-y-1">
<span className="text-xs font-medium text-slate-600">Status</span>
<select
value={filters.status ?? ""}
onChange={(event) =>
updateFilter("status", event.target.value as OrderStatus | "")
}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-slate-900"
>
<option value="">All</option>
<option value="PLACED">Placed</option>
<option value="CANCELLED">Cancelled</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-slate-600">From</span>
<input
type="date"
value={filters.from ?? ""}
onChange={(event) => updateFilter("from", event.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-slate-600">To</span>
<input
type="date"
value={filters.to ?? ""}
onChange={(event) => updateFilter("to", event.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
</label>
<div className="flex items-end">
<button
type="button"
onClick={() => onChange({status: "", from: "", to: ""})}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Reset filters
</button>
</div>
</div>
);
}
@@ -1,59 +0,0 @@
import {CancelOrderButton} from "#/components/orders/CancelOrderButton";
import {OrderStatusBadge} from "#/components/orders/OrderStatusBadge";
import type {OrderSummary} from "#/types/order";
import {formatCurrency} from "#/hooks/formatCurrency";
import {formatDate} from "#/hooks/formatDate";
type Props = {
orders: OrderSummary[];
};
export function OrderHistoryTable({orders}: Props) {
return (
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<table className="w-full border-collapse text-left text-sm">
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
<tr>
<th className="px-4 py-3">Order</th>
<th className="px-4 py-3">Customer</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Items</th>
<th className="px-4 py-3">Total</th>
<th className="px-4 py-3">Created</th>
<th className="px-4 py-3">Cancelled</th>
<th className="px-4 py-3">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{orders.map((order) => (
<tr key={order.id} className="hover:bg-slate-50">
<td className="px-4 py-3 font-mono text-xs text-slate-600">
{order.id.slice(0, 8)}
</td>
<td className="px-4 py-3 font-medium text-slate-900">
{order.customerName}
</td>
<td className="px-4 py-3">
<OrderStatusBadge status={order.status}/>
</td>
<td className="px-4 py-3 text-slate-600">{order.itemCount}</td>
<td className="px-4 py-3 font-medium text-slate-900">
{formatCurrency(order.totalAmount, order.currency)}
</td>
<td className="px-4 py-3 text-slate-600">
{formatDate(order.createdAt)}
</td>
<td className="px-4 py-3 text-slate-600">
{formatDate(order.cancelledAt)}
</td>
<td className="px-4 py-3">
<CancelOrderButton orderId={order.id} status={order.status}/>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -1,20 +0,0 @@
import {cn} from "#/lib/utils";
import type {OrderStatus} from "#/types/order";
type Props = {
status: OrderStatus;
};
export function OrderStatusBadge({status}: Props) {
return (
<span
className={cn(
"inline-flex rounded-full px-2.5 py-1 text-xs font-medium",
status === "PLACED" && "bg-blue-50 text-blue-700",
status === "CANCELLED" && "bg-slate-100 text-slate-600",
)}
>
{status === "PLACED" ? "Placed" : "Cancelled"}
</span>
);
}
@@ -0,0 +1,102 @@
import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"
import { Badge } from "#/components/ui/badge.tsx"
import {
Card,
CardAction,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "#/components/ui/card.tsx"
export function SectionCards() {
return (
<div className="grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4 dark:*:data-[slot=card]:bg-card">
<Card className="@container/card">
<CardHeader>
<CardDescription>Total Revenue</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
$1,250.00
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingUp />
+12.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Trending up this month <IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">
Visitors for the last 6 months
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>New Customers</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
1,234
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingDown />
-20%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Down 20% this period <IconTrendingDown className="size-4" />
</div>
<div className="text-muted-foreground">
Acquisition needs attention
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>Active Accounts</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
45,678
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingUp />
+12.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Strong user retention <IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">Engagement exceed targets</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>Growth Rate</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
4.5%
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingUp />
+4.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Steady performance increase <IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">Meets growth projections</div>
</CardFooter>
</Card>
</div>
)
}
@@ -0,0 +1,135 @@
import { Button } from "#/components/ui/button.tsx"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "#/components/ui/card.tsx"
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "#/components/ui/field.tsx"
import { Input } from "#/components/ui/input.tsx"
type SignupFormProps = React.ComponentProps<typeof Card> & {
displayName: string
username: string
password: string
confirmPassword: string
error?: string | null
pending?: boolean
onDisplayNameChange: (value: string) => void
onUsernameChange: (value: string) => void
onPasswordChange: (value: string) => void
onConfirmPasswordChange: (value: string) => void
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void
onLoginClick: () => void
}
export function SignupForm({
displayName,
username,
password,
confirmPassword,
error,
pending = false,
onDisplayNameChange,
onUsernameChange,
onPasswordChange,
onConfirmPasswordChange,
onSubmit,
onLoginClick,
...props
}: SignupFormProps) {
return (
<Card {...props}>
<CardHeader>
<CardTitle>Create an account</CardTitle>
<CardDescription>
Create a customer account to place orders and request rides.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="signup-display-name">Display name</FieldLabel>
<Input
id="signup-display-name"
type="text"
autoComplete="name"
value={displayName}
onChange={(event) => onDisplayNameChange(event.target.value)}
required
/>
</Field>
<Field>
<FieldLabel htmlFor="signup-username">Username</FieldLabel>
<Input
id="signup-username"
type="text"
autoComplete="username"
value={username}
onChange={(event) => onUsernameChange(event.target.value)}
required
/>
<FieldDescription>
This is what you will use when signing in.
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="signup-password">Password</FieldLabel>
<Input
id="signup-password"
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => onPasswordChange(event.target.value)}
required
/>
<FieldDescription>
Must be at least 8 characters long.
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="signup-confirm-password">
Confirm Password
</FieldLabel>
<Input
id="signup-confirm-password"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(event) => onConfirmPasswordChange(event.target.value)}
required
/>
<FieldDescription>Please confirm your password.</FieldDescription>
</Field>
{error ? <FieldError>{error}</FieldError> : null}
<FieldGroup>
<Field>
<Button type="submit" disabled={pending}>
{pending ? "Creating account..." : "Create account"}
</Button>
<FieldDescription className="px-6 text-center">
Already have an account?{" "}
<button
type="button"
className="underline underline-offset-4 hover:text-primary"
onClick={onLoginClick}
>
Sign in
</button>
</FieldDescription>
</Field>
</FieldGroup>
</FieldGroup>
</form>
</CardContent>
</Card>
)
}
@@ -0,0 +1,42 @@
import { Link } from "@tanstack/react-router"
import { ShoppingCart } from "lucide-react"
import { Button } from "#/components/ui/button.tsx"
import { Separator } from "#/components/ui/separator.tsx"
import { SidebarTrigger } from "#/components/ui/sidebar.tsx"
import { ThemeToggle } from "#/components/theme-toggle.tsx"
import { useCart } from "#/hooks/useCart.tsx"
type SiteHeaderProps = {
title: string
onLogout: () => void
showCart?: boolean
}
export function SiteHeader({ title, onLogout, showCart = false }: SiteHeaderProps) {
const cart = useCart()
return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mx-2 data-[orientation=vertical]:h-4" />
<h1 className="text-base font-medium">{title}</h1>
<div className="ml-auto flex items-center gap-2">
<ThemeToggle />
{showCart ? (
<Button asChild variant="outline" size="sm">
<Link to="/cart">
<ShoppingCart />
Cart ({cart.itemCount})
</Link>
</Button>
) : null}
<Button type="button" variant="outline" size="sm" onClick={onLogout}>
Sign out
</Button>
</div>
</div>
</header>
)
}
@@ -0,0 +1,128 @@
import { LoginForm } from "#/components/login-form.tsx"
import { SignupForm } from "#/components/signup-form.tsx"
import { Dialog, DialogContent } from "#/components/ui/dialog.tsx"
import { useEffect, useState } from "react"
type AuthDialogProps = {
open: boolean
initialMode: "login" | "register"
onOpenChange: (open: boolean) => void
onLogin: (request: { username: string; password: string }) => Promise<unknown>
onRegister: (request: {
username: string
password: string
displayName: string
}) => Promise<unknown>
}
export function AuthDialog({
open,
initialMode,
onOpenChange,
onLogin,
onRegister,
}: AuthDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md border-0 bg-transparent p-0 shadow-none">
<AuthForm
initialMode={initialMode}
onLogin={onLogin}
onRegister={onRegister}
/>
</DialogContent>
</Dialog>
)
}
export function AuthForm({
initialMode,
onLogin,
onRegister,
}: Omit<AuthDialogProps, "open" | "onOpenChange">) {
const [mode, setMode] = useState<"login" | "register">(initialMode)
const [username, setUsername] = useState("")
const [displayName, setDisplayName] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
useEffect(() => {
setMode(initialMode)
setError(null)
}, [initialMode])
async function handleLogin(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setError(null)
setPending(true)
try {
await onLogin({ username, password })
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Sign in failed")
} finally {
setPending(false)
}
}
async function handleSignup(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setError(null)
if (password !== confirmPassword) {
setError("Passwords do not match")
return
}
setPending(true)
try {
await onRegister({ username, displayName, password })
await onLogin({ username, password })
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Account creation failed")
} finally {
setPending(false)
}
}
if (mode === "register") {
return (
<SignupForm
displayName={displayName}
username={username}
password={password}
confirmPassword={confirmPassword}
error={error}
pending={pending}
onDisplayNameChange={setDisplayName}
onUsernameChange={setUsername}
onPasswordChange={setPassword}
onConfirmPasswordChange={setConfirmPassword}
onSubmit={handleSignup}
onLoginClick={() => {
setMode("login")
setError(null)
}}
/>
)
}
return (
<LoginForm
username={username}
password={password}
error={error}
pending={pending}
onUsernameChange={setUsername}
onPasswordChange={setPassword}
onSubmit={handleLogin}
onSignupClick={() => {
setMode("register")
setError(null)
}}
/>
)
}
@@ -0,0 +1,69 @@
import {QuantityStepper} from "#/components/store/QuantityStepper";
import {Button} from "#/components/ui/button";
import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "#/components/ui/card";
import {StatusBadge} from "#/components/ui/status-badge";
import {formatCurrency} from "#/hooks/formatCurrency";
import type {Product} from "#/types/product";
import {Link} from "@tanstack/react-router";
import {ShoppingCart} from "lucide-react";
import {useState} from "react";
import {toast} from "sonner";
type ProductCardProps = {
product: Product;
onAddToCart?: (product: Product, quantity: number) => void;
};
export function ProductCard({product, onAddToCart}: ProductCardProps) {
const [quantity, setQuantity] = useState(1);
const inStock = product.availableQuantity > 0;
return (
<Card className="gap-4">
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>{product.name}</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">{product.sku}</p>
</div>
<StatusBadge status={inStock ? "IN_STOCK" : "OUT_OF_STOCK"} label={inStock ? "In stock" : "Out"}/>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-2xl font-semibold">{formatCurrency(product.price, product.currency)}</p>
<p className="text-sm text-muted-foreground">{product.availableQuantity} available</p>
{onAddToCart ? (
<div className="flex flex-wrap items-center justify-between gap-3">
<QuantityStepper
value={Math.min(quantity, Math.max(product.availableQuantity, 1))}
min={1}
max={Math.max(product.availableQuantity, 1)}
disabled={!inStock}
onChange={setQuantity}
/>
<Button
type="button"
disabled={!inStock}
onClick={() => {
onAddToCart(product, quantity);
toast.success("Added to cart", {
description: `${quantity} x ${product.name}`,
});
}}
>
<ShoppingCart/>
Add
</Button>
</div>
) : null}
</CardContent>
<CardFooter>
<Button asChild variant="outline" className="w-full">
<Link to="/products/$productId" params={{productId: product.id}}>
View details
</Link>
</Button>
</CardFooter>
</Card>
);
}
@@ -0,0 +1,31 @@
import {ProductCard} from "#/components/store/ProductCard";
import {LoadingState} from "#/components/ui/LoadingState";
import type {Product} from "#/types/product";
type ProductGridProps = {
products: Product[];
isLoading: boolean;
onAddToCart?: (product: Product, quantity: number) => void;
};
export function ProductGrid({products, isLoading, onAddToCart}: ProductGridProps) {
if (isLoading) {
return <LoadingState title="Loading products"/>;
}
if (products.length === 0) {
return (
<div className="rounded-lg border border-dashed border-border bg-card p-8 text-center text-sm text-muted-foreground">
No products available.
</div>
);
}
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{products.map((product) => (
<ProductCard key={product.id} product={product} onAddToCart={onAddToCart}/>
))}
</div>
);
}
@@ -0,0 +1,61 @@
import {Button} from "#/components/ui/button";
import {Input} from "#/components/ui/input";
type QuantityStepperProps = {
value: number;
min?: number;
max: number;
disabled?: boolean;
onChange: (value: number) => void;
};
export function QuantityStepper({
value,
min = 1,
max,
disabled = false,
onChange,
}: QuantityStepperProps) {
const normalizedMax = Math.max(max, min);
function setQuantity(nextValue: number) {
if (Number.isNaN(nextValue)) {
onChange(min);
return;
}
onChange(Math.min(Math.max(nextValue, min), normalizedMax));
}
return (
<div className="inline-grid grid-cols-[2.25rem_4rem_2.25rem] overflow-hidden rounded-md border border-input">
<Button
type="button"
variant="secondary"
disabled={disabled || value <= min}
onClick={() => setQuantity(value - 1)}
className="h-9 rounded-none border-0 shadow-none"
>
-
</Button>
<Input
type="number"
min={min}
max={normalizedMax}
value={value}
disabled={disabled}
onChange={(event) => setQuantity(Number(event.target.value))}
className="h-9 rounded-none border-y-0 px-2 text-center shadow-none"
/>
<Button
type="button"
variant="secondary"
disabled={disabled || value >= normalizedMax}
onClick={() => setQuantity(value + 1)}
className="h-9 rounded-none border-0 shadow-none"
>
+
</Button>
</div>
);
}
@@ -0,0 +1,54 @@
import {Button} from "#/components/ui/button";
import {ThemeToggle} from "#/components/theme-toggle";
import {useCart} from "#/hooks/useCart";
import type {AuthUser} from "#/types/auth";
import {Link} from "@tanstack/react-router";
import {LogOut, ShoppingCart, User} from "lucide-react";
type SiteHeaderProps = {
user?: AuthUser | null;
onLogin?: () => void;
onRegister?: () => void;
onLogout?: () => void;
};
export function SiteHeader({user, onLogin, onRegister, onLogout}: SiteHeaderProps) {
const cart = useCart();
return (
<header className="sticky top-0 z-30 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-7xl flex-col gap-4 px-4 py-4 md:flex-row md:items-center md:justify-between">
<div>
<Link to="/" className="text-2xl font-bold text-foreground">Cymelle</Link>
<p className="text-sm text-muted-foreground">Commerce, delivery, and rides</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<ThemeToggle/>
<Button asChild variant="outline">
<Link to="/cart">
<ShoppingCart/>
Cart ({cart.itemCount})
</Link>
</Button>
{user ? (
<>
<div className="inline-flex items-center gap-2 rounded-md border border-border bg-card px-3 py-2 text-sm">
<User className="size-4"/>
<span>{user.displayName}</span>
</div>
<Button type="button" variant="outline" onClick={onLogout}>
<LogOut/>
Sign out
</Button>
</>
) : (
<>
<Button type="button" onClick={onLogin}>Sign in</Button>
<Button type="button" variant="outline" onClick={onRegister}>Create account</Button>
</>
)}
</div>
</div>
</header>
);
}
@@ -0,0 +1,60 @@
import {AuthDialog} from "#/components/store/AuthDialog";
import {ProductGrid} from "#/components/store/ProductGrid";
import {SiteHeader} from "#/components/store/SiteHeader";
import {useAuth} from "#/hooks/useAuth";
import {useCart} from "#/hooks/useCart";
import {useProducts} from "#/hooks/useProducts";
import {navigateToRoleHome} from "#/lib/auth-navigation";
import {useNavigate} from "@tanstack/react-router";
import {useState} from "react";
export function StoreHome() {
const {user, login, register, logout} = useAuth();
const productsQuery = useProducts();
const cart = useCart();
const navigate = useNavigate();
const [authMode, setAuthMode] = useState<"login" | "register" | null>(null);
async function handleLogin(request: {username: string; password: string}) {
const user = await login(request);
setAuthMode(null);
await navigateToRoleHome(navigate, user);
return user;
}
async function handleRegister(request: {username: string; password: string; displayName: string}) {
const user = await register(request);
setAuthMode(null);
await navigateToRoleHome(navigate, user);
return user;
}
return (
<main className="min-h-screen bg-background">
<SiteHeader
user={user}
onLogin={() => setAuthMode("login")}
onRegister={() => setAuthMode("register")}
onLogout={logout}
/>
<section className="mx-auto max-w-7xl px-4 py-8">
<div className="mb-6 max-w-2xl">
<p className="text-sm font-medium uppercase text-muted-foreground">Store</p>
<h1 className="mt-2 text-4xl font-bold tracking-normal text-foreground">Shop Cymelle products</h1>
</div>
<ProductGrid
products={productsQuery.data ?? []}
isLoading={productsQuery.isLoading}
onAddToCart={cart.addItem}
/>
</section>
<AuthDialog
open={authMode !== null}
initialMode={authMode ?? "login"}
onOpenChange={(open) => !open && setAuthMode(null)}
onLogin={handleLogin}
onRegister={handleRegister}
/>
</main>
);
}
@@ -0,0 +1,81 @@
"use client"
import * as React from "react"
import { ChevronsUpDown } from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "#/components/ui/dropdown-menu.tsx"
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "#/components/ui/sidebar.tsx"
export function TeamSwitcher({
teams,
}: {
teams: {
name: string
logo: React.ElementType
plan: string
}[]
}) {
const { isMobile } = useSidebar()
const [activeTeam, setActiveTeam] = React.useState(teams[0])
if (!activeTeam) {
return null
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<activeTeam.logo className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{activeTeam.name}</span>
<span className="truncate text-xs">{activeTeam.plan}</span>
</div>
<ChevronsUpDown className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Workspace
</DropdownMenuLabel>
{teams.map((team, index) => (
<DropdownMenuItem
key={team.name}
onClick={() => setActiveTeam(team)}
className="gap-2 p-2"
>
<div className="flex size-6 items-center justify-center rounded-md border">
<team.logo className="size-3.5 shrink-0" />
</div>
{team.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
@@ -0,0 +1,14 @@
import {ThemeProvider as NextThemesProvider} from "next-themes";
export function ThemeProvider({children}: {children: React.ReactNode}) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
);
}
@@ -0,0 +1,38 @@
import {Button} from "#/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "#/components/ui/dropdown-menu";
import {Monitor, Moon, Sun} from "lucide-react";
import {useTheme} from "next-themes";
export function ThemeToggle() {
const {setTheme} = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" aria-label="Toggle theme">
<Sun className="size-4 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90"/>
<Moon className="absolute size-4 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0"/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun/>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon/>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor/>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -1,18 +1,21 @@
type LoadingStateProps = {
title?: string;
description?: string;
};
export function LoadingState({
title = "Loading",
description = "Fetching the latest data...",
}: LoadingStateProps) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-8 text-center shadow-sm">
<div
className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-slate-200 border-t-slate-900"/>
<h3 className="font-semibold text-slate-900">{title}</h3>
<p className="mt-1 text-sm text-slate-500">{description}</p>
<div className="rounded-lg border bg-card p-6 text-card-foreground shadow-sm" aria-label={title}>
<div className="space-y-3">
<div className="h-4 w-36 animate-pulse rounded-md bg-muted"/>
<div className="h-8 w-full animate-pulse rounded-md bg-muted"/>
<div className="grid gap-3 md:grid-cols-3">
<div className="h-16 animate-pulse rounded-md bg-muted"/>
<div className="h-16 animate-pulse rounded-md bg-muted"/>
<div className="h-16 animate-pulse rounded-md bg-muted"/>
</div>
</div>
</div>
);
}
}
@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "#/lib/utils.ts"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarBadge,
AvatarGroup,
AvatarGroupCount,
}
@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "#/lib/utils.ts"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
@@ -0,0 +1,109 @@
import * as React from "react"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { Slot } from "radix-ui"
import { cn } from "#/lib/utils.ts"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "#/lib/utils.ts"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "#/lib/utils.ts"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

Some files were not shown because too many files have changed in this diff Show More