diff --git a/orders-backend/.dockerignore b/orders-backend/.dockerignore new file mode 100644 index 0000000..4591c9f --- /dev/null +++ b/orders-backend/.dockerignore @@ -0,0 +1,9 @@ +.env +.gradle +build +out +.idea +*.iml + +Dockerfile +.dockerignore diff --git a/orders-backend/Dockerfile b/orders-backend/Dockerfile new file mode 100644 index 0000000..169bf88 --- /dev/null +++ b/orders-backend/Dockerfile @@ -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"] diff --git a/orders-backend/docker-entrypoint.sh b/orders-backend/docker-entrypoint.sh new file mode 100644 index 0000000..f605178 --- /dev/null +++ b/orders-backend/docker-entrypoint.sh @@ -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 diff --git a/orders-backend/gradle/wrapper/gradle-wrapper.properties b/orders-backend/gradle/wrapper/gradle-wrapper.properties index df6a6ad..95dae55 100644 --- a/orders-backend/gradle/wrapper/gradle-wrapper.properties +++ b/orders-backend/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/orders-backend/src/main/java/com/andwati/orders/config/CorsConfig.java b/orders-backend/src/main/java/com/andwati/orders/config/CorsConfig.java index 6a7f07d..5184a15 100644 --- a/orders-backend/src/main/java/com/andwati/orders/config/CorsConfig.java +++ b/orders-backend/src/main/java/com/andwati/orders/config/CorsConfig.java @@ -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); } }; } -} \ No newline at end of file + + private String[] parseAllowedOrigins() { + return allowedOrigins.split("\\s*,\\s*"); + } +} diff --git a/orders-backend/src/main/java/com/andwati/orders/config/DataSeeder.java b/orders-backend/src/main/java/com/andwati/orders/config/DataSeeder.java index 56cd137..8c6269c 100644 --- a/orders-backend/src/main/java/com/andwati/orders/config/DataSeeder.java +++ b/orders-backend/src/main/java/com/andwati/orders/config/DataSeeder.java @@ -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 customers, List drivers, List products) { + Instant now = Instant.now(); + List 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) { + } } diff --git a/orders-backend/src/main/java/com/andwati/orders/config/OrderStatusMigrator.java b/orders-backend/src/main/java/com/andwati/orders/config/OrderStatusMigrator.java new file mode 100644 index 0000000..28cd21e --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/config/OrderStatusMigrator.java @@ -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. + } + } +} diff --git a/orders-backend/src/main/java/com/andwati/orders/config/SecurityConfig.java b/orders-backend/src/main/java/com/andwati/orders/config/SecurityConfig.java new file mode 100644 index 0000000..bfcda59 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/config/SecurityConfig.java @@ -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(); + } +} diff --git a/orders-backend/src/main/java/com/andwati/orders/controller/AuthController.java b/orders-backend/src/main/java/com/andwati/orders/controller/AuthController.java new file mode 100644 index 0000000..0fb4df5 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/controller/AuthController.java @@ -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() + ); + } +} diff --git a/orders-backend/src/main/java/com/andwati/orders/controller/OrderController.java b/orders-backend/src/main/java/com/andwati/orders/controller/OrderController.java index 8169a8b..2c1c105 100644 --- a/orders-backend/src/main/java/com/andwati/orders/controller/OrderController.java +++ b/orders-backend/src/main/java/com/andwati/orders/controller/OrderController.java @@ -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 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()); + } } diff --git a/orders-backend/src/main/java/com/andwati/orders/controller/ProductController.java b/orders-backend/src/main/java/com/andwati/orders/controller/ProductController.java new file mode 100644 index 0000000..dfdbe3e --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/controller/ProductController.java @@ -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 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); + } +} diff --git a/orders-backend/src/main/java/com/andwati/orders/controller/RideController.java b/orders-backend/src/main/java/com/andwati/orders/controller/RideController.java new file mode 100644 index 0000000..9653626 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/controller/RideController.java @@ -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 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()); + } +} diff --git a/orders-backend/src/main/java/com/andwati/orders/controller/UserController.java b/orders-backend/src/main/java/com/andwati/orders/controller/UserController.java new file mode 100644 index 0000000..8b0db3c --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/controller/UserController.java @@ -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 listUsers() { + return appUserService.listUsers(); + } + + @GetMapping("/customers") + @PreAuthorize("hasRole('ADMIN')") + public List listCustomers() { + return appUserService.listCustomers(); + } + + @GetMapping("/drivers") + @PreAuthorize("hasRole('ADMIN')") + public List 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); + } +} diff --git a/orders-backend/src/main/java/com/andwati/orders/dto/request/AdminUserRequest.java b/orders-backend/src/main/java/com/andwati/orders/dto/request/AdminUserRequest.java new file mode 100644 index 0000000..376880d --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/dto/request/AdminUserRequest.java @@ -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 +) { +} diff --git a/orders-backend/src/main/java/com/andwati/orders/dto/request/CreateOrderRequest.java b/orders-backend/src/main/java/com/andwati/orders/dto/request/CreateOrderRequest.java index fa98095..213347d 100644 --- a/orders-backend/src/main/java/com/andwati/orders/dto/request/CreateOrderRequest.java +++ b/orders-backend/src/main/java/com/andwati/orders/dto/request/CreateOrderRequest.java @@ -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 items + List items, + + @Valid + CreateOrderRideRequest deliveryRide ) { } diff --git a/orders-backend/src/main/java/com/andwati/orders/dto/request/CreateOrderRideRequest.java b/orders-backend/src/main/java/com/andwati/orders/dto/request/CreateOrderRideRequest.java new file mode 100644 index 0000000..aee0392 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/dto/request/CreateOrderRideRequest.java @@ -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 +) { +} diff --git a/orders-backend/src/main/java/com/andwati/orders/dto/request/CreateRideRequest.java b/orders-backend/src/main/java/com/andwati/orders/dto/request/CreateRideRequest.java new file mode 100644 index 0000000..6e60f1e --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/dto/request/CreateRideRequest.java @@ -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 +) { +} diff --git a/orders-backend/src/main/java/com/andwati/orders/dto/request/LoginRequest.java b/orders-backend/src/main/java/com/andwati/orders/dto/request/LoginRequest.java new file mode 100644 index 0000000..cf5d1ab --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/dto/request/LoginRequest.java @@ -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 +) { +} diff --git a/orders-backend/src/main/java/com/andwati/orders/dto/request/ProductRequest.java b/orders-backend/src/main/java/com/andwati/orders/dto/request/ProductRequest.java new file mode 100644 index 0000000..87e45e6 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/dto/request/ProductRequest.java @@ -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 +) { +} diff --git a/orders-backend/src/main/java/com/andwati/orders/dto/request/RegisterRequest.java b/orders-backend/src/main/java/com/andwati/orders/dto/request/RegisterRequest.java new file mode 100644 index 0000000..73f75e8 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/dto/request/RegisterRequest.java @@ -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 +) { +} diff --git a/orders-backend/src/main/java/com/andwati/orders/dto/request/UpdateOrderStatusRequest.java b/orders-backend/src/main/java/com/andwati/orders/dto/request/UpdateOrderStatusRequest.java new file mode 100644 index 0000000..9acd9c6 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/dto/request/UpdateOrderStatusRequest.java @@ -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 +) { +} diff --git a/orders-backend/src/main/java/com/andwati/orders/dto/request/UpdateRideStatusRequest.java b/orders-backend/src/main/java/com/andwati/orders/dto/request/UpdateRideStatusRequest.java new file mode 100644 index 0000000..569d0fd --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/dto/request/UpdateRideStatusRequest.java @@ -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 +) { +} diff --git a/orders-backend/src/main/java/com/andwati/orders/dto/response/AuthUserResponse.java b/orders-backend/src/main/java/com/andwati/orders/dto/response/AuthUserResponse.java new file mode 100644 index 0000000..f2e910c --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/dto/response/AuthUserResponse.java @@ -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 +) { +} diff --git a/orders-backend/src/main/java/com/andwati/orders/dto/response/CsrfResponse.java b/orders-backend/src/main/java/com/andwati/orders/dto/response/CsrfResponse.java new file mode 100644 index 0000000..b7f618b --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/dto/response/CsrfResponse.java @@ -0,0 +1,8 @@ +package com.andwati.orders.dto.response; + +public record CsrfResponse( + String headerName, + String parameterName, + String token +) { +} diff --git a/orders-backend/src/main/java/com/andwati/orders/dto/response/OrderResponse.java b/orders-backend/src/main/java/com/andwati/orders/dto/response/OrderResponse.java index 521a801..0f21b0e 100644 --- a/orders-backend/src/main/java/com/andwati/orders/dto/response/OrderResponse.java +++ b/orders-backend/src/main/java/com/andwati/orders/dto/response/OrderResponse.java @@ -9,6 +9,7 @@ import java.util.UUID; public record OrderResponse( UUID id, + UUID customerId, String customerName, OrderStatus status, List items, @@ -17,4 +18,4 @@ public record OrderResponse( Instant createdAt, Instant cancelledAt ) { -} \ No newline at end of file +} diff --git a/orders-backend/src/main/java/com/andwati/orders/dto/response/OrderSummaryResponse.java b/orders-backend/src/main/java/com/andwati/orders/dto/response/OrderSummaryResponse.java index 49250a2..f263580 100644 --- a/orders-backend/src/main/java/com/andwati/orders/dto/response/OrderSummaryResponse.java +++ b/orders-backend/src/main/java/com/andwati/orders/dto/response/OrderSummaryResponse.java @@ -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 ) { -} \ No newline at end of file +} diff --git a/orders-backend/src/main/java/com/andwati/orders/dto/response/ProductResponse.java b/orders-backend/src/main/java/com/andwati/orders/dto/response/ProductResponse.java new file mode 100644 index 0000000..6cb8f5e --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/dto/response/ProductResponse.java @@ -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 +) { +} diff --git a/orders-backend/src/main/java/com/andwati/orders/dto/response/RideResponse.java b/orders-backend/src/main/java/com/andwati/orders/dto/response/RideResponse.java new file mode 100644 index 0000000..4fb7d59 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/dto/response/RideResponse.java @@ -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 +) { +} diff --git a/orders-backend/src/main/java/com/andwati/orders/dto/response/UserResponse.java b/orders-backend/src/main/java/com/andwati/orders/dto/response/UserResponse.java new file mode 100644 index 0000000..cd6f784 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/dto/response/UserResponse.java @@ -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 +) { +} diff --git a/orders-backend/src/main/java/com/andwati/orders/exception/GlobalExceptionHandler.java b/orders-backend/src/main/java/com/andwati/orders/exception/GlobalExceptionHandler.java index 3e007b0..8a0d19a 100644 --- a/orders-backend/src/main/java/com/andwati/orders/exception/GlobalExceptionHandler.java +++ b/orders-backend/src/main/java/com/andwati/orders/exception/GlobalExceptionHandler.java @@ -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 handleBadCredentials( + BadCredentialsException exception, + HttpServletRequest request + ) { + return build( + HttpStatus.UNAUTHORIZED, + "AUTHENTICATION_FAILED", + "Invalid username or password", + null, + request + ); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity 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 handleUnexpected( Exception exception, @@ -197,4 +227,4 @@ public class GlobalExceptionHandler { return ResponseEntity.status(status).body(response); } -} \ No newline at end of file +} diff --git a/orders-backend/src/main/java/com/andwati/orders/exception/OrderNotCancellableException.java b/orders-backend/src/main/java/com/andwati/orders/exception/OrderNotCancellableException.java index d904db3..1b6d6fe 100644 --- a/orders-backend/src/main/java/com/andwati/orders/exception/OrderNotCancellableException.java +++ b/orders-backend/src/main/java/com/andwati/orders/exception/OrderNotCancellableException.java @@ -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"); } -} \ No newline at end of file +} diff --git a/orders-backend/src/main/java/com/andwati/orders/mappers/OrderMapper.java b/orders-backend/src/main/java/com/andwati/orders/mappers/OrderMapper.java index a9720ac..fa3e3bc 100644 --- a/orders-backend/src/main/java/com/andwati/orders/mappers/OrderMapper.java +++ b/orders-backend/src/main/java/com/andwati/orders/mappers/OrderMapper.java @@ -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() ); } -} \ No newline at end of file +} diff --git a/orders-backend/src/main/java/com/andwati/orders/mappers/ProductMapper.java b/orders-backend/src/main/java/com/andwati/orders/mappers/ProductMapper.java new file mode 100644 index 0000000..96a24bf --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/mappers/ProductMapper.java @@ -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() + ); + } +} diff --git a/orders-backend/src/main/java/com/andwati/orders/mappers/RideMapper.java b/orders-backend/src/main/java/com/andwati/orders/mappers/RideMapper.java new file mode 100644 index 0000000..e7c6e98 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/mappers/RideMapper.java @@ -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() + ); + } +} diff --git a/orders-backend/src/main/java/com/andwati/orders/model/AppUser.java b/orders-backend/src/main/java/com/andwati/orders/model/AppUser.java new file mode 100644 index 0000000..606f0b8 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/model/AppUser.java @@ -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; + } +} diff --git a/orders-backend/src/main/java/com/andwati/orders/model/Order.java b/orders-backend/src/main/java/com/andwati/orders/model/Order.java index a39b1fe..f82ba23 100644 --- a/orders-backend/src/main/java/com/andwati/orders/model/Order.java +++ b/orders-backend/src/main/java/com/andwati/orders/model/Order.java @@ -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; } diff --git a/orders-backend/src/main/java/com/andwati/orders/model/OrderStatus.java b/orders-backend/src/main/java/com/andwati/orders/model/OrderStatus.java index 6f1662c..c0a2148 100644 --- a/orders-backend/src/main/java/com/andwati/orders/model/OrderStatus.java +++ b/orders-backend/src/main/java/com/andwati/orders/model/OrderStatus.java @@ -2,6 +2,8 @@ package com.andwati.orders.model; public enum OrderStatus { - PLACED, + PENDING, + SHIPPED, + DELIVERED, CANCELLED -} \ No newline at end of file +} diff --git a/orders-backend/src/main/java/com/andwati/orders/model/Ride.java b/orders-backend/src/main/java/com/andwati/orders/model/Ride.java new file mode 100644 index 0000000..cdcc612 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/model/Ride.java @@ -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; + } +} diff --git a/orders-backend/src/main/java/com/andwati/orders/model/RideStatus.java b/orders-backend/src/main/java/com/andwati/orders/model/RideStatus.java new file mode 100644 index 0000000..37a4f68 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/model/RideStatus.java @@ -0,0 +1,8 @@ +package com.andwati.orders.model; + +public enum RideStatus { + REQUESTED, + ACCEPTED, + COMPLETED, + CANCELLED +} diff --git a/orders-backend/src/main/java/com/andwati/orders/model/Role.java b/orders-backend/src/main/java/com/andwati/orders/model/Role.java new file mode 100644 index 0000000..6d3ede2 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/model/Role.java @@ -0,0 +1,7 @@ +package com.andwati.orders.model; + +public enum Role { + ADMIN, + CUSTOMER, + DRIVER +} diff --git a/orders-backend/src/main/java/com/andwati/orders/repository/AppUserRepository.java b/orders-backend/src/main/java/com/andwati/orders/repository/AppUserRepository.java new file mode 100644 index 0000000..ab26dae --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/repository/AppUserRepository.java @@ -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 { + + Optional findByUsernameIgnoreCase(String username); + + boolean existsByUsernameIgnoreCase(String username); + + boolean existsByRole(Role role); + + List findByRoleOrderByCreatedAtDesc(Role role); + + List findAllByOrderByCreatedAtDesc(); +} diff --git a/orders-backend/src/main/java/com/andwati/orders/repository/OrderRepository.java b/orders-backend/src/main/java/com/andwati/orders/repository/OrderRepository.java index 6ed51b0..95e0b49 100644 --- a/orders-backend/src/main/java/com/andwati/orders/repository/OrderRepository.java +++ b/orders-backend/src/main/java/com/andwati/orders/repository/OrderRepository.java @@ -10,6 +10,6 @@ import java.util.UUID; public interface OrderRepository extends JpaRepository, JpaSpecificationExecutor { - @EntityGraph(attributePaths = {"items", "items.product"}) + @EntityGraph(attributePaths = {"items", "items.product", "customer"}) Optional findWithItemsById(UUID id); -} \ No newline at end of file +} diff --git a/orders-backend/src/main/java/com/andwati/orders/repository/ProductRepository.java b/orders-backend/src/main/java/com/andwati/orders/repository/ProductRepository.java index 25ff8cd..001f7d9 100644 --- a/orders-backend/src/main/java/com/andwati/orders/repository/ProductRepository.java +++ b/orders-backend/src/main/java/com/andwati/orders/repository/ProductRepository.java @@ -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 { List findByIdIn(Collection ids); -} \ No newline at end of file + + Optional findBySkuIgnoreCase(String sku); +} diff --git a/orders-backend/src/main/java/com/andwati/orders/repository/RideRepository.java b/orders-backend/src/main/java/com/andwati/orders/repository/RideRepository.java new file mode 100644 index 0000000..927c782 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/repository/RideRepository.java @@ -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 { + + @EntityGraph(attributePaths = {"customer", "driver", "order"}) + List findByCustomer_IdOrderByRequestedAtDesc(UUID customerId); + + @EntityGraph(attributePaths = {"customer", "driver", "order"}) + List findByDriver_IdOrderByRequestedAtDesc(UUID driverId); + + @EntityGraph(attributePaths = {"customer", "driver", "order"}) + List findByStatusOrderByRequestedAtAsc(RideStatus status); + + @EntityGraph(attributePaths = {"customer", "driver", "order"}) + List findAllByOrderByRequestedAtDesc(); + + @EntityGraph(attributePaths = {"customer", "driver", "order"}) + Optional findWithUsersById(UUID id); + + boolean existsByOrder_Id(UUID orderId); +} diff --git a/orders-backend/src/main/java/com/andwati/orders/services/AppUserDetailsService.java b/orders-backend/src/main/java/com/andwati/orders/services/AppUserDetailsService.java new file mode 100644 index 0000000..49af835 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/services/AppUserDetailsService.java @@ -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())) + ); + } +} diff --git a/orders-backend/src/main/java/com/andwati/orders/services/AppUserService.java b/orders-backend/src/main/java/com/andwati/orders/services/AppUserService.java new file mode 100644 index 0000000..e183fbd --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/services/AppUserService.java @@ -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 listUsers() { + return appUserRepository.findAllByOrderByCreatedAtDesc() + .stream() + .map(this::toUserResponse) + .toList(); + } + + @Transactional(readOnly = true) + public List listCustomers() { + return appUserRepository.findByRoleOrderByCreatedAtDesc(Role.CUSTOMER) + .stream() + .map(this::toUserResponse) + .toList(); + } + + @Transactional(readOnly = true) + public List 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(); + } +} diff --git a/orders-backend/src/main/java/com/andwati/orders/services/OrderService.java b/orders-backend/src/main/java/com/andwati/orders/services/OrderService.java index 574c515..198a6d9 100644 --- a/orders-backend/src/main/java/com/andwati/orders/services/OrderService.java +++ b/orders-backend/src/main/java/com/andwati/orders/services/OrderService.java @@ -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 requestedQuantities = normalizeItems(request); Map 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 normalizeItems(CreateOrderRequest request) { Map quantities = new HashMap<>(); @@ -236,7 +286,8 @@ public class OrderService { private Specification buildOrderSpecification( OrderStatus status, LocalDate from, - LocalDate to + LocalDate to, + AppUser currentUser ) { return (root, query, criteriaBuilder) -> { var predicates = new java.util.ArrayList(); @@ -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"); + } } diff --git a/orders-backend/src/main/java/com/andwati/orders/services/ProductService.java b/orders-backend/src/main/java/com/andwati/orders/services/ProductService.java index 243441f..0d69410 100644 --- a/orders-backend/src/main/java/com/andwati/orders/services/ProductService.java +++ b/orders-backend/src/main/java/com/andwati/orders/services/ProductService.java @@ -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 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)); } -} \ No newline at end of file + + private Role currentRoleOrCustomer() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated() || "anonymousUser".equals(authentication.getName())) { + return Role.CUSTOMER; + } + + return appUserService.getCurrentUser().getRole(); + } +} diff --git a/orders-backend/src/main/java/com/andwati/orders/services/RideService.java b/orders-backend/src/main/java/com/andwati/orders/services/RideService.java new file mode 100644 index 0000000..3455315 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/services/RideService.java @@ -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 listRides() { + AppUser currentUser = appUserService.getCurrentUser(); + + List 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(); + } + } +} diff --git a/orders-backend/src/main/resources/application.properties b/orders-backend/src/main/resources/application.properties index 0dc6f3d..46c9a07 100644 --- a/orders-backend/src/main/resources/application.properties +++ b/orders-backend/src/main/resources/application.properties @@ -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 \ No newline at end of file +springdoc.swagger-ui.try-it-out-enabled=true diff --git a/orders-backend/src/test/java/com/andwati/orders/OrdersBackendApplicationTests.java b/orders-backend/src/test/java/com/andwati/orders/OrdersBackendApplicationTests.java index 5103d09..defe1f5 100644 --- a/orders-backend/src/test/java/com/andwati/orders/OrdersBackendApplicationTests.java +++ b/orders-backend/src/test/java/com/andwati/orders/OrdersBackendApplicationTests.java @@ -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 diff --git a/orders-frontend/.dockerignore b/orders-frontend/.dockerignore new file mode 100644 index 0000000..ab2ca41 --- /dev/null +++ b/orders-frontend/.dockerignore @@ -0,0 +1,14 @@ +.env +node_modules +.output +dist +dist-ssr +.nitro +.tanstack +.vinxi +.wrangler +.idea +.vscode + +Dockerfile +.dockerignore diff --git a/orders-frontend/Dockerfile b/orders-frontend/Dockerfile new file mode 100644 index 0000000..8d75c39 --- /dev/null +++ b/orders-frontend/Dockerfile @@ -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"] diff --git a/orders-frontend/package.json b/orders-frontend/package.json index 2d0f30c..3f0bf62 100644 --- a/orders-frontend/package.json +++ b/orders-frontend/package.json @@ -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" - ] } } \ No newline at end of file diff --git a/orders-frontend/pnpm-lock.yaml b/orders-frontend/pnpm-lock.yaml index 215bda9..ba48b0f 100644 --- a/orders-frontend/pnpm-lock.yaml +++ b/orders-frontend/pnpm-lock.yaml @@ -8,9 +8,24 @@ importers: .: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/modifiers': + specifier: ^9.0.0 + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.7) '@faker-js/faker': specifier: ^10.3.0 version: 10.4.0 + '@tabler/icons-react': + specifier: ^3.44.0 + version: 3.44.0(react@19.2.7) '@tailwindcss/vite': specifier: ^4.1.18 version: 4.3.0(vite@8.0.16(@types/node@22.19.21)(jiti@2.7.0)) @@ -62,6 +77,9 @@ importers: lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.2.7) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) nitro: specifier: npm:nitro-nightly@latest version: nitro-nightly@3.0.1-20260611-123640-7765bcb7(chokidar@5.0.0)(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.61.1)(vite@8.0.16(@types/node@22.19.21)(jiti@2.7.0)) @@ -74,6 +92,12 @@ importers: react-dom: specifier: ^19.2.0 version: 19.2.7(react@19.2.7) + recharts: + specifier: 3.8.0 + version: 3.8.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@17.0.2)(react@19.2.7)(redux@5.0.1) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) 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/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) 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/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/modifiers@9.0.0': + resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -1240,6 +1295,17 @@ packages: '@radix-ui/rect@1.1.2': resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} + '@reduxjs/toolkit@2.12.0': + 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/binding-android-arm64@1.0.3': resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1621,6 +1687,17 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + + '@tabler/icons-react@3.44.0': + resolution: {integrity: sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==} + peerDependencies: + react: '>= 16' + + '@tabler/icons@3.44.0': + resolution: {integrity: sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==} + '@tailwindcss/node@4.3.0': resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} @@ -2024,6 +2101,33 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -2041,6 +2145,9 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@vitejs/plugin-react@6.0.2': resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2203,6 +2310,50 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + data-urls@7.0.0: 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 + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -2298,6 +2452,9 @@ packages: es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-toolkit@1.47.0: + resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -2305,6 +2462,9 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -2387,6 +2547,16 @@ packages: httpxy@0.5.3: resolution: {integrity: sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.8: + resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-fullwidth-code-point@3.0.0: 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 + next-themes@0.4.6: + 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 + nf3@0.3.17: resolution: {integrity: sha512-N9zEWySuJFw+gR0lhS5863YsvNeudOdqRyFvNb+jMXbeTJOdrjDqkCpDginIZfUm0LzT1t1nCRiDeqQm/8kirQ==} @@ -2645,6 +2821,18 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-redux@9.3.0: + 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 + react-remove-scroll-bar@2.3.8: 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'} + recharts@3.8.0: + 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 + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + remove-accents@0.5.0: 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'} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + rolldown@1.0.3: resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2743,6 +2950,12 @@ packages: solid-js@1.9.13: resolution: {integrity: sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ==} + sonner@2.0.7: + 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 + source-map-js@1.2.1: 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'} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2949,6 +3165,15 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vaul@1.1.2: + 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 + + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite@8.0.16: resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3305,6 +3530,38 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.7)': + dependencies: + react: 19.2.7 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.7) + '@dnd-kit/utilities': 3.2.2(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + tslib: 2.8.1 + + '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/utilities': 3.2.2(react@19.2.7) + react: 19.2.7 + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/utilities': 3.2.2(react@19.2.7) + react: 19.2.7 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.7)': + dependencies: + react: 19.2.7 + tslib: 2.8.1 + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -4224,6 +4481,18 @@ snapshots: '@radix-ui/rect@1.1.2': {} + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7)': + 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(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.7 + react-redux: 9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1) + '@rolldown/binding-android-arm64@1.0.3': optional: true @@ -4444,6 +4713,15 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} + + '@tabler/icons-react@3.44.0(react@19.2.7)': + dependencies: + '@tabler/icons': 3.44.0 + react: 19.2.7 + + '@tabler/icons@3.44.0': {} + '@tailwindcss/node@4.3.0': dependencies: '@jridgewell/remapping': 2.3.5 @@ -4904,6 +5182,30 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.9': {} @@ -4920,6 +5222,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/use-sync-external-store@0.0.6': {} + '@vitejs/plugin-react@6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@22.19.21)(jiti@2.7.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.16(@types/node@22.19.21)(jiti@2.7.0))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -5074,6 +5378,44 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + 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 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + data-urls@7.0.0: dependencies: whatwg-mimetype: 5.0.0 @@ -5089,6 +5431,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} dequal@2.0.3: {} @@ -5121,12 +5465,16 @@ snapshots: es-module-lexer@2.1.0: {} + es-toolkit@1.47.0: {} + escalade@3.2.0: {} estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 + eventemitter3@5.0.4: {} + expect-type@1.3.0: {} exsolve@1.0.8: {} @@ -5190,6 +5538,12 @@ snapshots: httpxy@0.5.3: {} + immer@10.2.0: {} + + immer@11.1.8: {} + + internmap@2.0.3: {} + is-fullwidth-code-point@3.0.0: {} is-potential-custom-element-name@1.0.1: {} @@ -5311,6 +5665,11 @@ snapshots: nanoid@3.3.12: {} + next-themes@0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + nf3@0.3.17: {} nitro-nightly@3.0.1-20260611-123640-7765bcb7(chokidar@5.0.0)(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.61.1)(vite@8.0.16(@types/node@22.19.21)(jiti@2.7.0)): @@ -5506,6 +5865,15 @@ snapshots: react-is@17.0.2: {} + react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + redux: 5.0.1 + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7): dependencies: react: 19.2.7 @@ -5537,12 +5905,40 @@ snapshots: readdirp@5.0.0: {} + recharts@3.8.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@17.0.2)(react@19.2.7)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7) + 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(react@19.2.7) + react-is: 17.0.2 + react-redux: 9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.7) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + remove-accents@0.5.0: {} require-directory@2.1.1: {} require-from-string@2.0.2: {} + reselect@5.1.1: {} + rolldown@1.0.3: dependencies: '@oxc-project/types': 0.133.0 @@ -5643,6 +6039,11 @@ snapshots: seroval: 1.5.4 seroval-plugins: 1.5.4(seroval@1.5.4) + sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + source-map-js@1.2.1: {} source-map@0.7.6: {} @@ -5671,6 +6072,8 @@ snapshots: tapable@2.3.3: {} + tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} tinyexec@1.2.4: {} @@ -5752,6 +6155,32 @@ snapshots: util-deprecate@1.0.2: {} + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@radix-ui/react-dialog': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + victory-vendor@37.3.6: + 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 + vite@8.0.16(@types/node@22.19.21)(jiti@2.7.0): dependencies: lightningcss: 1.32.0 diff --git a/orders-frontend/src/api/auth.ts b/orders-frontend/src/api/auth.ts new file mode 100644 index 0000000..ab3d492 --- /dev/null +++ b/orders-frontend/src/api/auth.ts @@ -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("/auth/me", {signal}); +} + +export function login(request: LoginRequest) { + return apiRequest("/auth/login", { + method: "POST", + body: request, + }); +} + +export function register(request: RegisterRequest) { + return apiRequest("/auth/register", { + method: "POST", + body: request, + }); +} + +export async function logout() { + await apiRequest("/auth/logout", {method: "POST"}); + clearRequestSecurity(); +} diff --git a/orders-frontend/src/api/client.ts b/orders-frontend/src/api/client.ts index 5c14e84..4a115bd 100644 --- a/orders-frontend/src/api/client.ts +++ b/orders-frontend/src/api/client.ts @@ -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( path: string, options: RequestOptions = {}, ): Promise { + 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( return undefined as T; } - return response.json() as Promise; -} \ No newline at end of file + 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(); +} diff --git a/orders-frontend/src/api/orders.ts b/orders-frontend/src/api/orders.ts index de494d6..cd9a893 100644 --- a/orders-frontend/src/api/orders.ts +++ b/orders-frontend/src/api/orders.ts @@ -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(`/orders/${id}`, {signal}); } +export function createOrder(request: CreateOrderRequest) { + return apiRequest("/orders", { + method: "POST", + body: request, + }); +} + export function cancelOrder(id: string) { return apiRequest(`/orders/${id}`, { method: "DELETE", }); -} \ No newline at end of file +} + +export function updateOrderStatus(id: string, status: OrderStatus) { + return apiRequest(`/orders/${id}/status`, { + method: "PATCH", + body: {status}, + }); +} diff --git a/orders-frontend/src/api/products.ts b/orders-frontend/src/api/products.ts new file mode 100644 index 0000000..fb56245 --- /dev/null +++ b/orders-frontend/src/api/products.ts @@ -0,0 +1,28 @@ +import {apiRequest} from "#/api/client"; +import type {Product, ProductRequest} from "#/types/product"; + +export function getProducts(signal?: AbortSignal) { + return apiRequest("/products", {signal}); +} + +export function getProduct(id: string, signal?: AbortSignal) { + return apiRequest(`/products/${id}`, {signal}); +} + +export function createProduct(request: ProductRequest) { + return apiRequest("/products", { + method: "POST", + body: request, + }); +} + +export function updateProduct(id: string, request: ProductRequest) { + return apiRequest(`/products/${id}`, { + method: "PUT", + body: request, + }); +} + +export function deactivateProduct(id: string) { + return apiRequest(`/products/${id}`, {method: "DELETE"}); +} diff --git a/orders-frontend/src/api/rides.ts b/orders-frontend/src/api/rides.ts new file mode 100644 index 0000000..5975d42 --- /dev/null +++ b/orders-frontend/src/api/rides.ts @@ -0,0 +1,32 @@ +import {apiRequest} from "#/api/client"; +import type {CreateRideRequest, Ride, RideStatus} from "#/types/ride"; + +export function getRides(signal?: AbortSignal) { + return apiRequest("/rides", {signal}); +} + +export function requestRide(request: CreateRideRequest) { + return apiRequest("/rides", { + method: "POST", + body: request, + }); +} + +export function acceptRide(id: string) { + return apiRequest(`/rides/${id}/accept`, {method: "POST"}); +} + +export function completeRide(id: string) { + return apiRequest(`/rides/${id}/complete`, {method: "POST"}); +} + +export function cancelRide(id: string) { + return apiRequest(`/rides/${id}`, {method: "DELETE"}); +} + +export function updateRideStatus(id: string, status: RideStatus) { + return apiRequest(`/rides/${id}/status`, { + method: "PATCH", + body: {status}, + }); +} diff --git a/orders-frontend/src/api/users.ts b/orders-frontend/src/api/users.ts new file mode 100644 index 0000000..f08bce1 --- /dev/null +++ b/orders-frontend/src/api/users.ts @@ -0,0 +1,32 @@ +import {apiRequest} from "#/api/client"; +import type {AdminUserRequest, UserSummary} from "#/types/user"; + +export function getUsers(signal?: AbortSignal) { + return apiRequest("/users", {signal}); +} + +export function getCustomers(signal?: AbortSignal) { + return apiRequest("/users/customers", {signal}); +} + +export function getDrivers(signal?: AbortSignal) { + return apiRequest("/users/drivers", {signal}); +} + +export function createUser(request: AdminUserRequest) { + return apiRequest("/users", { + method: "POST", + body: request, + }); +} + +export function updateUser(id: string, request: AdminUserRequest) { + return apiRequest(`/users/${id}`, { + method: "PUT", + body: request, + }); +} + +export function disableUser(id: string) { + return apiRequest(`/users/${id}`, {method: "DELETE"}); +} diff --git a/orders-frontend/src/components/app-sidebar.tsx b/orders-frontend/src/components/app-sidebar.tsx new file mode 100644 index 0000000..aa1baf1 --- /dev/null +++ b/orders-frontend/src/components/app-sidebar.tsx @@ -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 = { + ADMIN: "Admin workspace", + CUSTOMER: "Customer store", + DRIVER: "Driver workspace", +} + +const navMainByRole: Record["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 & { + user: AuthUser + onLogout?: () => void +} + +export function AppSidebar({ user, onLogout, ...props }: AppSidebarProps) { + return ( + + + + + + + + + + + + + ) +} diff --git a/orders-frontend/src/components/app/AppShell.tsx b/orders-frontend/src/components/app/AppShell.tsx new file mode 100644 index 0000000..814677a --- /dev/null +++ b/orders-frontend/src/components/app/AppShell.tsx @@ -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 ; + } + + if (!user) { + return ; + } + + const allowed = !allowedRoles || allowedRoles.includes(user.role); + + return ( + + + + +
+ {allowed ? children : } +
+
+
+ ); +} + +function WorkspaceSkeleton() { + return ( +
+
+
+ + +
+
+ + + +
+ +
+
+ ); +} + +function AccessDenied() { + return ( + + +

This page is not available for your role

+
+ +

+ Switch to the correct account type to view this workspace. +

+
+
+ ); +} diff --git a/orders-frontend/src/components/app/WorkspacePages.tsx b/orders-frontend/src/components/app/WorkspacePages.tsx new file mode 100644 index 0000000..319c3c4 --- /dev/null +++ b/orders-frontend/src/components/app/WorkspacePages.tsx @@ -0,0 +1,1595 @@ +import {AppShell} from "#/components/app/AppShell"; +import {ProductGrid} from "#/components/store/ProductGrid"; +import {StoreHome} from "#/components/store/StoreHome"; +import {ErrorState} from "#/components/ui/ErrorState"; +import {LoadingState} from "#/components/ui/LoadingState"; +import {Button} from "#/components/ui/button"; +import {Card, CardContent, CardHeader, CardTitle} from "#/components/ui/card"; +import {ChartContainer, ChartTooltip, ChartTooltipContent} from "#/components/ui/chart"; +import {Input} from "#/components/ui/input"; +import {Label} from "#/components/ui/label"; +import {StatusBadge, statusColorClass} from "#/components/ui/status-badge"; +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 {useInventory, useLowStockInventory} from "#/hooks/useInventory"; +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 { + useCreateUser, + useDisableUser, + useUpdateUser, + useUsers, +} from "#/hooks/useUsers"; +import type {Role} from "#/types/auth"; +import type {InventoryItem} from "#/types/inventory"; +import type {OrderStatus, OrderSummary} from "#/types/order"; +import type {Product, ProductRequest} from "#/types/product"; +import type {Ride, RideStatus} from "#/types/ride"; +import type {AdminUserRequest, UserSummary} from "#/types/user"; +import {Link} from "@tanstack/react-router"; +import {Boxes, Car, ClipboardList, PackagePlus, Search, Users} from "lucide-react"; +import {useMemo, useState, type FormEvent, type ReactNode} from "react"; +import {Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis} from "recharts"; +import {toast} from "sonner"; + +const emptyProductForm: ProductRequest = { + name: "", + sku: "", + price: 0, + currency: "KES", + active: true, + availableQuantity: 0, + reorderLevel: 5, +}; + +const emptyUserForm: AdminUserRequest = { + username: "", + displayName: "", + password: "", + role: "CUSTOMER", + enabled: true, +}; + +export function ProductListingPage({featured = false}: {featured?: boolean}) { + const {user} = useAuth(); + + if (!user) { + return ; + } + + return ( + + + + ); +} + +function ProductsContent({featured}: {featured: boolean}) { + const productsQuery = useProducts(); + const cart = useCart(); + const [searchQuery, setSearchQuery] = useState(""); + const products = useMemo(() => { + const items = productsQuery.data ?? []; + const visible = featured + ? items + .filter((product) => product.active && product.availableQuantity > 0) + .sort((a, b) => b.availableQuantity - a.availableQuantity) + .slice(0, 6) + : items; + return visible.filter((product) => matchesText(searchQuery, product.name, product.sku, product.currency)); + }, [featured, productsQuery.data, searchQuery]); + + if (productsQuery.isError) { + return productsQuery.refetch()}/>; + } + + return ( + + + + + ); +} + +export function AdminOverviewPage() { + return ( + + + + ); +} + +function AdminOverviewContent() { + const productsQuery = useProducts(); + const inventoryQuery = useInventory(); + const ordersQuery = useOrders({status: "", from: "", to: ""}, 0, 100); + const ridesQuery = useRides(); + + const products = productsQuery.data ?? []; + const inventory = inventoryQuery.data?.items ?? []; + const orders = ordersQuery.data?.items ?? []; + const rides = ridesQuery.data ?? []; + const revenueTrend = useMemo(() => dailyOrderRevenue(orders), [orders]); + const orderStatusData = useMemo(() => statusCounts(orders, ["PENDING", "SHIPPED", "DELIVERED", "CANCELLED"]), [orders]); + const rideStatusData = useMemo(() => statusCounts(rides, ["REQUESTED", "ACCEPTED", "COMPLETED", "CANCELLED"]), [rides]); + const inventoryDepth = useMemo(() => inventory.slice(0, 8).map((item) => ({ + name: compactLabel(item.productName), + available: item.availableQuantity, + reorder: item.reorderLevel, + })), [inventory]); + const revenue = orders + .filter((order) => order.status !== "CANCELLED") + .reduce((total, order) => total + order.totalAmount, 0); + + return ( + +
+ + item.status !== "IN_STOCK").length)} + detail="Inventory alerts" + /> + order.status === "PENDING").length)} + detail="Awaiting fulfillment" + /> + +
+
+ }> + + + }> + + +
+
+ }> + + + }> + + +
+
+ }> + order.status === "PENDING")}/> + + }> + ride.status === "REQUESTED")}/> + +
+
+ ); +} + +export function CustomerOverviewPage() { + return ( + + + + ); +} + +function CustomerOverviewContent() { + const productsQuery = useProducts(); + const ordersQuery = useOrders({status: "", from: "", to: ""}, 0, 100); + const ridesQuery = useRides(); + const cart = useCart(); + const products = productsQuery.data ?? []; + const orders = ordersQuery.data?.items ?? []; + const rides = ridesQuery.data ?? []; + const spendTrend = useMemo(() => dailyOrderRevenue(orders), [orders]); + const orderStatusData = useMemo(() => statusCounts(orders, ["PENDING", "SHIPPED", "DELIVERED", "CANCELLED"]), [orders]); + const rideStatusData = useMemo(() => statusCounts(rides, ["REQUESTED", "ACCEPTED", "COMPLETED", "CANCELLED"]), [rides]); + const spend = orders + .filter((order) => order.status !== "CANCELLED") + .reduce((total, order) => total + order.totalAmount, 0); + + return ( + +
+ + + order.status === "PENDING" || order.status === "SHIPPED").length)} detail="Awaiting delivery"/> + product.active && product.availableQuantity > 0).length)} detail="Ready to add to cart"/> +
+
+ }> + + + }> + + +
+
+ }> + + + }> + + +
+
+ ); +} + +export function DriverOverviewPage() { + return ( + + + + ); +} + +function DriverOverviewContent() { + const ridesQuery = useRides(); + const rides = ridesQuery.data ?? []; + const statusData = useMemo(() => statusCounts(rides, ["REQUESTED", "ACCEPTED", "COMPLETED", "CANCELLED"]), [rides]); + const completedTrend = useMemo(() => dailyRideCounts(rides), [rides]); + const fareTrend = useMemo(() => dailyRideFares(rides), [rides]); + const assignedRides = rides.filter((ride) => ride.driverId && ride.status !== "CANCELLED"); + const totalFare = assignedRides.reduce((total, ride) => total + (ride.fareAmount ?? 0), 0); + + return ( + +
+ ride.status === "REQUESTED").length)} detail="Open ride requests"/> + ride.status === "ACCEPTED").length)} detail="Assigned routes"/> + ride.status === "COMPLETED").length)} detail="Finished deliveries"/> + +
+
+ }> + + + }> + + +
+
+ }> + + + }> + ride.status === "REQUESTED" || ride.status === "ACCEPTED").slice(0, 8)} loading={ridesQuery.isLoading}/> + +
+
+ ); +} + +export function AdminInventoryPage() { + return ( + + + + ); +} + +function AdminInventoryContent() { + const inventoryQuery = useInventory(); + const lowStockQuery = useLowStockInventory(); + const [searchQuery, setSearchQuery] = useState(""); + const inventory = inventoryQuery.data?.items ?? []; + const filteredInventory = useMemo(() => inventory.filter((item) => + matchesText(searchQuery, item.productName, item.sku, item.status) + ), [inventory, searchQuery]); + + return ( + +
+ + + item.status === "OUT_OF_STOCK").length)} + detail="Cannot be sold" + /> +
+ }> +
+ + +
+
+
+ ); +} + +export function AdminProductsPage() { + return ( + + + + ); +} + +function AdminProductsContent() { + const productsQuery = useProducts(); + const createProduct = useCreateProduct(); + const updateProduct = useUpdateProduct(); + const deactivateProduct = useDeactivateProduct(); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState(emptyProductForm); + const [searchQuery, setSearchQuery] = useState(""); + const products = productsQuery.data ?? []; + const filteredProducts = useMemo(() => products.filter((product) => + matchesText(searchQuery, product.name, product.sku, product.currency, product.active ? "active" : "inactive") + ), [products, searchQuery]); + + async function submitProduct(event: FormEvent) { + event.preventDefault(); + try { + 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); + } catch (error) { + toastError(error, editing ? "Could not update product" : "Could not create product"); + } + } + + 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, + }); + } + + return ( + + }> + + + }> +
+ + + deactivateProduct.mutate(id, { + onSuccess: () => toast.warning("Product deactivated"), + onError: (error) => toastError(error, "Could not deactivate product"), + }) + } + /> +
+
+
+ ); +} + +export function AdminProductCreatePage() { + return ( + + + + ); +} + +function AdminProductCreateContent() { + const createProduct = useCreateProduct(); + const [form, setForm] = useState(emptyProductForm); + + async function submitProduct(event: FormEvent) { + event.preventDefault(); + try { + await createProduct.mutateAsync(form); + setForm(emptyProductForm); + toast.success("Product created"); + } catch (error) { + toastError(error, "Could not create product"); + } + } + + return ( + + }> + + + + ); +} + +export function AdminOrdersPage({status}: {status?: OrderStatus}) { + return ( + + + + ); +} + +export function CustomerOrdersPage({status}: {status?: OrderStatus}) { + return ( + + + + ); +} + +function OrdersContent({admin = false, status}: {admin?: boolean; status?: OrderStatus}) { + const ordersQuery = useOrders({status: status ?? "", from: "", to: ""}); + const updateOrderStatus = useUpdateOrderStatus(); + const [searchQuery, setSearchQuery] = useState(""); + const orders = ordersQuery.data?.items ?? []; + const filteredOrders = useMemo(() => orders.filter((order) => + matchesText(searchQuery, order.id, order.customerName, order.status, order.currency) + ), [orders, searchQuery]); + + return ( + + }> +
+ + ( + + value={order.status} + options={["PENDING", "SHIPPED", "DELIVERED", "CANCELLED"]} + onChange={(nextStatus) => + updateOrderStatus.mutate( + {id: order.id, status: nextStatus}, + { + onSuccess: () => orderStatusToast(nextStatus), + onError: (error) => toastError(error, "Could not update order"), + }, + ) + } + /> + ) + : undefined + } + /> +
+
+
+ ); +} + +export function AdminRidesPage({status}: {status?: RideStatus}) { + return ( + + + + ); +} + +export function CustomerRidesPage() { + return ( + + + + ); +} + +export function CustomerRideRequestPage() { + return ( + + + + ); +} + +export function DriverRidesPage({status}: {status?: RideStatus}) { + return ( + + + + ); +} + +function RideRequestContent() { + const requestRide = useRequestRide(); + const ordersQuery = useOrders({status: "", from: "", to: ""}); + const [form, setForm] = useState({orderId: "", pickupLocation: "", dropoffLocation: "", distanceKm: "5"}); + + async function submitRide(event: FormEvent) { + event.preventDefault(); + if (!form.orderId) { + toast.error("Select an order first"); + return; + } + try { + await requestRide.mutateAsync({ + orderId: form.orderId, + pickupLocation: form.pickupLocation, + dropoffLocation: form.dropoffLocation, + distanceKm: Number(form.distanceKm), + }); + setForm({orderId: "", pickupLocation: "", dropoffLocation: "", distanceKm: "5"}); + toast.success("Ride requested", {description: "Drivers can now accept this delivery."}); + } catch (error) { + toastError(error, "Could not request ride"); + } + } + + return ( + + }> + + + + ); +} + +function RidesContent({admin = false, driver = false, status}: {admin?: boolean; driver?: boolean; status?: RideStatus}) { + const ridesQuery = useRides(); + const updateRideStatus = useUpdateRideStatus(); + const acceptRide = useAcceptRide(); + const completeRide = useCompleteRide(); + const cancelRide = useCancelRide(); + const [searchQuery, setSearchQuery] = useState(""); + const rides = (ridesQuery.data ?? []).filter((ride) => !status || ride.status === status); + const filteredRides = useMemo(() => rides.filter((ride) => + matchesText( + searchQuery, + ride.id, + ride.orderId ?? "", + ride.customerName, + ride.driverName ?? "", + ride.pickupLocation, + ride.dropoffLocation, + ride.status, + ) + ), [rides, searchQuery]); + + return ( + + }> +
+ + { + if (admin) { + return ( + + value={ride.status} + options={["REQUESTED", "ACCEPTED", "COMPLETED", "CANCELLED"]} + onChange={(nextStatus) => + updateRideStatus.mutate( + {id: ride.id, status: nextStatus}, + { + onSuccess: () => rideStatusToast(nextStatus), + onError: (error) => toastError(error, "Could not update ride"), + }, + ) + } + /> + ); + } + if (driver && ride.status === "REQUESTED") { + return ( + + ); + } + if (driver && ride.status === "ACCEPTED") { + return ( + + ); + } + if (!driver && !admin && ride.status === "REQUESTED") { + return ( + + ); + } + return null; + }} + /> +
+
+
+ ); +} + +export function AdminCustomersPage() { + return ( + + + + ); +} + +export function AdminDriversPage() { + return ( + + + + ); +} + +export function AdminUsersPage() { + return ( + + + + ); +} + +function AdminUsersContent({roleFilter, title}: {roleFilter?: Role; title: string}) { + const usersQuery = useUsers(); + const createUser = useCreateUser(); + const updateUser = useUpdateUser(); + const disableUser = useDisableUser(); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState({ + ...emptyUserForm, + role: roleFilter ?? "CUSTOMER", + }); + const [searchQuery, setSearchQuery] = useState(""); + const users = (usersQuery.data ?? []).filter((user) => !roleFilter || user.role === roleFilter); + const filteredUsers = useMemo(() => users.filter((user) => + matchesText(searchQuery, user.displayName, user.username, user.role, user.enabled ? "enabled" : "disabled") + ), [users, searchQuery]); + + function startEdit(user: UserSummary) { + setEditing(user); + setForm({ + username: user.username, + displayName: user.displayName, + password: "", + role: user.role, + enabled: user.enabled, + }); + } + + async function submitUser(event: FormEvent) { + event.preventDefault(); + const request = {...form, role: roleFilter ?? form.role}; + try { + if (editing) { + await updateUser.mutateAsync({id: editing.id, request}); + toast.success("User updated"); + } else { + await createUser.mutateAsync(request); + toast.success("User created"); + } + setEditing(null); + setForm({...emptyUserForm, role: roleFilter ?? "CUSTOMER"}); + } catch (error) { + toastError(error, editing ? "Could not update user" : "Could not create user"); + } + } + + return ( + + }> + + + }> +
+ + + disableUser.mutate(id, { + onSuccess: () => toast.warning("User disabled"), + onError: (error) => toastError(error, "Could not disable user"), + }) + } + /> +
+
+
+ ); +} + +function PageStack({ + eyebrow, + title, + description, + children, +}: { + eyebrow: string; + title: string; + description: string; + children: ReactNode; +}) { + return ( +
+
+

{eyebrow}

+

{title}

+

{description}

+
+ {children} +
+ ); +} + +function MetricCard({title, value, detail}: {title: string; value: string; detail: string}) { + return ( + + +

{title}

+

{value}

+

{detail}

+
+
+ ); +} + +function Panel({title, icon, children}: {title: string; icon: ReactNode; children: ReactNode}) { + return ( + + + + {icon} + {title} + + + {children} + + ); +} + +function SearchField({ + value, + onChange, + placeholder, +}: { + value: string; + onChange: (value: string) => void; + placeholder: string; +}) { + return ( +
+ + onChange(event.target.value)} + placeholder={placeholder} + className="pl-9" + aria-label={placeholder} + /> +
+ ); +} + +function RevenueAreaChart({data}: {data: {label: string; revenue: number}[]}) { + return ( + + + + + formatShortNumber(value)}/> + }/> + + + + ); +} + +function FareAreaChart({data}: {data: {label: string; fare: number}[]}) { + return ( + + + + + formatShortNumber(value)}/> + }/> + + + + ); +} + +function RideActivityChart({data}: {data: {label: string; completed: number}[]}) { + return ( + + + + + + }/> + + + + ); +} + +function StatusBarChart({data}: {data: {status: string; count: number}[]}) { + return ( + + + + + + }/> + + {data.map((item) => ( + + ))} + + + + ); +} + +function StatusPieChart({data}: {data: {status: string; count: number}[]}) { + return ( + + + }/> + + {data.map((item) => ( + + ))} + + + + ); +} + +function InventoryBarChart({data}: {data: {name: string; available: number; reorder: number}[]}) { + return ( + + + + + + }/> + + + + + ); +} + +function ProductForm({ + form, + setForm, + onSubmit, + pending, + submitLabel, +}: { + form: ProductRequest; + setForm: (form: ProductRequest) => void; + onSubmit: (event: FormEvent) => void; + pending: boolean; + submitLabel: string; +}) { + return ( +
+ setForm({...form, name: value})}/> + setForm({...form, sku: value})}/> + setForm({...form, price: value})}/> + setForm({...form, currency: value})}/> + setForm({...form, availableQuantity: value})}/> + setForm({...form, reorderLevel: value})}/> + +
+ +
+ + ); +} + +function ProductAdminTable({ + products, + loading, + onEdit, + onDeactivate, +}: { + products: Product[]; + loading: boolean; + onEdit: (product: Product) => void; + onDeactivate: (id: string) => void; +}) { + if (loading) { + return ; + } + + return ( + + + + Product + Price + Stock + Status + Action + + + + {products.map((product) => ( + + + +

{product.sku}

+
+ {formatCurrency(product.price, product.currency)} + {product.availableQuantity} + + +
+ + +
+
+
+ ))} +
+
+ ); +} + +function InventoryTable({items, loading}: {items: InventoryItem[]; loading: boolean}) { + if (loading) { + return ; + } + + return ( + + + + Product + Available + Reserved + Reorder + Status + Updated + + + + {items.map((item) => ( + + +

{item.productName}

+

{item.sku}

+
+ {item.availableQuantity} + {item.reservedQuantity} + {item.reorderLevel} + + {formatDate(item.updatedAt)} +
+ ))} +
+
+ ); +} + +function OrderTable({ + orders, + loading = false, + action, +}: { + orders: OrderSummary[]; + loading?: boolean; + action?: (order: OrderSummary) => ReactNode; +}) { + if (loading) { + return ; + } + + if (orders.length === 0) { + return ; + } + + return ( + + + + Order + Customer + Status + Items + Total + Created + {action ? Action : null} + + + + {orders.map((order) => ( + + {order.id.slice(0, 8)} + {order.customerName} + + {order.itemCount} + {formatCurrency(order.totalAmount, order.currency)} + {formatDate(order.createdAt)} + {action ? {action(order)} : null} + + ))} + +
+ ); +} + +function RideRequestForm({ + form, + setForm, + orders, + onSubmit, + pending, +}: { + form: {orderId: string; pickupLocation: string; dropoffLocation: string; distanceKm: string}; + setForm: (form: {orderId: string; pickupLocation: string; dropoffLocation: string; distanceKm: string}) => void; + orders: OrderSummary[]; + onSubmit: (event: FormEvent) => void; + pending: boolean; +}) { + return ( +
+
+ + +
+ setForm({...form, pickupLocation: value})}/> + setForm({...form, dropoffLocation: value})}/> + setForm({...form, distanceKm: value})}/> +
+ +
+ + ); +} + +function RideTable({ + rides, + loading = false, + action, +}: { + rides: Ride[]; + loading?: boolean; + action?: (ride: Ride) => ReactNode; +}) { + if (loading) { + return ; + } + + if (rides.length === 0) { + return ; + } + + return ( + + + + Ride + Route + Status + Driver + Fare + Requested + {action ? Action : null} + + + + {rides.map((ride) => ( + + {ride.id.slice(0, 8)} + {ride.pickupLocation} to {ride.dropoffLocation} + + {ride.driverName ?? "-"} + {ride.fareAmount ? formatCurrency(ride.fareAmount, ride.currency) : "-"} + {formatDate(ride.requestedAt)} + {action ? {action(ride)} : null} + + ))} + +
+ ); +} + +function UserForm({ + form, + setForm, + roleLocked, + pending, + onSubmit, + submitLabel, +}: { + form: AdminUserRequest; + setForm: (form: AdminUserRequest) => void; + roleLocked?: Role; + pending: boolean; + onSubmit: (event: FormEvent) => void; + submitLabel: string; +}) { + return ( +
+ setForm({...form, username: value})}/> + setForm({...form, displayName: value})}/> + setForm({...form, password: value})}/> +
+ + +
+ +
+ +
+ + ); +} + +function UserTable({ + users, + loading, + onEdit, + onDisable, +}: { + users: UserSummary[]; + loading: boolean; + onEdit: (user: UserSummary) => void; + onDisable: (id: string) => void; +}) { + if (loading) { + return ; + } + + if (users.length === 0) { + return ; + } + + return ( + + + + Name + Username + Role + Status + Created + Action + + + + {users.map((user) => ( + + {user.displayName} + {user.username} + + + {formatDate(user.createdAt)} + +
+ + +
+
+
+ ))} +
+
+ ); +} + +function StatusSelect({ + value, + options, + onChange, +}: { + value: T; + options: T[]; + onChange: (value: T) => void; +}) { + return ( + + ); +} + +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 ( +
+ + onChange(event.target.value)} + required + /> +
+ ); +} + +function NumberField({label, value, onChange}: {label: string; value: number; onChange: (value: number) => void}) { + return onChange(Number(value))}/>; +} + +function EmptyPanel({message}: {message: string}) { + return ( +
+ {message} +
+ ); +} + +function dailyOrderRevenue(orders: OrderSummary[]) { + const days = lastDays(7); + return days.map((day) => ({ + label: day.label, + revenue: orders + .filter((order) => order.status !== "CANCELLED" && dateKey(order.createdAt) === day.key) + .reduce((total, order) => total + order.totalAmount, 0), + })); +} + +function dailyRideCounts(rides: Ride[]) { + const days = lastDays(7); + return days.map((day) => ({ + label: day.label, + completed: rides.filter((ride) => ride.status === "COMPLETED" && ride.completedAt && dateKey(ride.completedAt) === day.key).length, + })); +} + +function dailyRideFares(rides: Ride[]) { + const days = lastDays(7); + return days.map((day) => ({ + label: day.label, + fare: rides + .filter((ride) => ride.fareAmount && ride.acceptedAt && dateKey(ride.acceptedAt) === day.key) + .reduce((total, ride) => total + (ride.fareAmount ?? 0), 0), + })); +} + +function statusCounts(items: T[], statuses: string[]) { + return statuses.map((status) => ({ + status, + count: items.filter((item) => item.status === status).length, + })); +} + +function lastDays(count: number) { + const formatter = new Intl.DateTimeFormat(undefined, {month: "short", day: "numeric"}); + return Array.from({length: count}, (_, index) => { + const date = new Date(); + date.setHours(0, 0, 0, 0); + date.setDate(date.getDate() - (count - index - 1)); + return { + key: date.toISOString().slice(0, 10), + label: formatter.format(date), + }; + }); +} + +function dateKey(value: string) { + return new Date(value).toISOString().slice(0, 10); +} + +function compactLabel(value: string) { + const words = value.split(/\s+/); + if (words.length === 1) { + return value.slice(0, 10); + } + return words.map((word) => word[0]).join("").slice(0, 6).toUpperCase(); +} + +function formatShortNumber(value: number) { + return new Intl.NumberFormat(undefined, {notation: "compact", maximumFractionDigits: 1}).format(value); +} + +function chartColorForStatus(status: string) { + switch (status) { + case "DELIVERED": + case "COMPLETED": + return "var(--chart-2)"; + case "PENDING": + case "REQUESTED": + return "var(--chart-4)"; + case "SHIPPED": + case "ACCEPTED": + return "var(--chart-1)"; + case "CANCELLED": + return "var(--chart-5)"; + default: + return "var(--chart-3)"; + } +} + +function matchesText(query: string, ...values: Array) { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return true; + } + + return values.some((value) => String(value ?? "").toLowerCase().includes(normalizedQuery)); +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : "Request failed"; +} + +function toastError(error: unknown, fallback: string) { + toast.error(fallback, {description: errorMessage(error)}); +} + +function orderStatusToast(status: OrderStatus) { + if (status === "CANCELLED") { + toast.warning("Order cancelled"); + return; + } + + if (status === "DELIVERED") { + toast.success("Order delivered"); + return; + } + + toast.info("Order updated", {description: `Status changed to ${status}.`}); +} + +function rideStatusToast(status: RideStatus) { + if (status === "CANCELLED") { + toast.warning("Ride cancelled"); + return; + } + + if (status === "COMPLETED") { + toast.success("Ride completed", {description: "The attached order was marked delivered."}); + return; + } + + toast.info("Ride updated", {description: `Status changed to ${status}.`}); +} diff --git a/orders-frontend/src/components/chart-area-interactive.tsx b/orders-frontend/src/components/chart-area-interactive.tsx new file mode 100644 index 0000000..0314ae6 --- /dev/null +++ b/orders-frontend/src/components/chart-area-interactive.tsx @@ -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 ( + + + Total Visitors + + + Total for the last 3 months + + Last 3 months + + + + Last 3 months + Last 30 days + Last 7 days + + + + + + + + + + + + + + + + + + + { + const date = new Date(value) + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }) + }} + /> + { + return new Date(value).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }) + }} + indicator="dot" + /> + } + /> + + + + + + + ) +} diff --git a/orders-frontend/src/components/dashboard/Dashboard.tsx b/orders-frontend/src/components/dashboard/Dashboard.tsx index 52a9f43..a1e0096 100644 --- a/orders-frontend/src/components/dashboard/Dashboard.tsx +++ b/orders-frontend/src/components/dashboard/Dashboard.tsx @@ -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("inventory"); + const {user, logout} = useAuth(); + + if (!user) { + return ; + } return ( -
-
-
-

- Ops Dashboard -

-

- Order Management + Fare Engine -

-

- Monitor stock levels, review order history, cancel orders with stock - rollback, and calculate trip fares. -

-
- -
- -
- -
- setActiveTab("inventory")} - icon={} - > - Inventory - - - setActiveTab("history")} - icon={} - > - Trip / Order History - -
- - {activeTab === "inventory" ? : } -
-
+ + + + +
+ {user.role === "CUSTOMER" ? : null} + {user.role === "ADMIN" ? : null} + {user.role === "DRIVER" ? : null} +
+
+
); } -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) { + 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 productsQuery.refetch()}/>; + } + + return ( +
+
+ }> + + + + }> + } + /> + + + }> + + + ride.status === "REQUESTED" ? ( + + ) : null + } + /> + +
+ + +
+ ); +} + +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(null); + const [form, setForm] = useState(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) { + 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 ( +
+ }> + + + + }> + + deactivateProduct.mutate(id, {onSuccess: () => toast.success("Product deactivated")}) + } + /> + + + }> + ( + + value={order.status} + options={["PENDING", "SHIPPED", "DELIVERED", "CANCELLED"]} + onChange={(status) => + updateOrderStatus.mutate({id: order.id, status}, {onSuccess: () => toast.success("Order updated")}) + } + /> + )} + /> + + + }> + ( + + value={ride.status} + options={["REQUESTED", "ACCEPTED", "COMPLETED", "CANCELLED"]} + onChange={(status) => + updateRideStatus.mutate({id: ride.id, status}, {onSuccess: () => toast.success("Ride updated")}) + } + /> + )} + /> + +
+ ); +} + +function DriverDashboard() { + const ridesQuery = useRides(); + const acceptRide = useAcceptRide(); + const completeRide = useCompleteRide(); + + return ( + }> + {ridesQuery.isLoading ? : null} + { + if (ride.status === "REQUESTED") { + return ( + + ); + } + if (ride.status === "ACCEPTED") { + return ( + + ); + } + return null; + }} + /> + + ); +} + +function Panel({title, icon, children}: {title: string; icon: ReactNode; children: ReactNode}) { + return ( + + + + {icon} + {title} + + + {children} + + ); +} + +function ProductForm({ + form, + setForm, + onSubmit, + pending, +}: { + form: ProductRequest; + setForm: (form: ProductRequest) => void; + onSubmit: (event: FormEvent) => void; + pending: boolean; +}) { + return ( +
+ setForm({...form, name: value})}/> + setForm({...form, sku: value})}/> + setForm({...form, price: value})}/> + setForm({...form, currency: value})}/> + setForm({...form, availableQuantity: value})}/> + setForm({...form, reorderLevel: value})}/> + +
+ +
+ + ); +} + +function ProductAdminTable({ + products, + onEdit, + onDeactivate, +}: { + products: Product[]; + onEdit: (product: Product) => void; + onDeactivate: (id: string) => void; +}) { + return ( + + + + Product + Price + Stock + Status + Action + + + + {products.map((product) => ( + + +

{product.name}

+

{product.sku}

+
+ {formatCurrency(product.price, product.currency)} + {product.availableQuantity} + {product.active ? "Active" : "Inactive"} + +
+ + +
+
+
+ ))} +
+
+ ); +} + +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) => ReactNode; +}) { return ( - + + + + Order + Customer + Status + Items + Total + Created + Action + + + + {orders.map((order) => ( + + {order.id.slice(0, 8)} + {order.customerName} + + {order.itemCount} + {formatCurrency(order.totalAmount, order.currency)} + {formatDate(order.createdAt)} + {action(order)} + + ))} + +
); -} \ No newline at end of file +} + +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) => void; + pending: boolean; +}) { + return ( +
+ setForm({...form, pickupLocation: value})}/> + setForm({...form, dropoffLocation: value})}/> + setForm({...form, distanceKm: value})}/> +
+ +
+ + ); +} + +function RideTable({ + rides, + action, +}: { + rides: Ride[]; + action: (ride: Ride) => ReactNode; +}) { + return ( + + + + Ride + Route + Status + Driver + Fare + Requested + Action + + + + {rides.map((ride) => ( + + {ride.id.slice(0, 8)} + {ride.pickupLocation} to {ride.dropoffLocation} + + {ride.driverName ?? "-"} + {ride.fareAmount ? formatCurrency(ride.fareAmount, ride.currency) : "-"} + {formatDate(ride.requestedAt)} + {action(ride)} + + ))} + +
+ ); +} + +function StatusSelect({ + value, + options, + onChange, +}: { + value: T; + options: T[]; + onChange: (value: T) => void; +}) { + return ( + + ); +} + +function OrderStatusBadge({status}: {status: OrderStatus}) { + return {status}; +} + +function RideStatusBadge({status}: {status: RideStatus}) { + return {status}; +} + +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 ( +
+ + onChange(event.target.value)} + required + /> +
+ ); +} + +function NumberField({label, value, onChange}: {label: string; value: number; onChange: (value: number) => void}) { + return onChange(Number(value))}/>; +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : "Request failed"; +} diff --git a/orders-frontend/src/components/dashboard/FareCalculatorCard.tsx b/orders-frontend/src/components/dashboard/FareCalculatorCard.tsx deleted file mode 100644 index e09c981..0000000 --- a/orders-frontend/src/components/dashboard/FareCalculatorCard.tsx +++ /dev/null @@ -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(null); - - const fareMutation = useFareCalculation(); - - function handleSubmit(event: React.FormEvent) { - 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 ( -
-
-
- -
-
-

Fare calculator

-

- Base fare + distance rate, with surge and minimum fare applied. -

-
-
- -
- - - - -
- -
-
- - {validationError ? ( -

- {validationError} -

- ) : null} - - {fareMutation.isError ? ( -

- {fareMutation.error instanceof Error - ? fareMutation.error.message - : "Failed to calculate fare"} -

- ) : null} - - {data ? ( -
- - - - - -
- ) : null} -
- ); -} - -type FareMetricProps = { - label: string; - value: string; - strong?: boolean; -}; - -function FareMetric({label, value, strong = false}: FareMetricProps) { - return ( -
-

{label}

-

- {value} -

-
- ); -} \ No newline at end of file diff --git a/orders-frontend/src/components/dashboard/InventoryView.tsx b/orders-frontend/src/components/dashboard/InventoryView.tsx deleted file mode 100644 index 50bb777..0000000 --- a/orders-frontend/src/components/dashboard/InventoryView.tsx +++ /dev/null @@ -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 ( - - ); - } - - if (inventoryQuery.isError) { - return ( - inventoryQuery.refetch()} - /> - ); - } - - const items = inventoryQuery.data?.items ?? []; - - if (items.length === 0) { - return ( - - ); - } - - const lowStockCount = items.filter((item) => item.status === "LOW_STOCK").length; - const outOfStockCount = items.filter( - (item) => item.status === "OUT_OF_STOCK", - ).length; - - return ( -
-
-
-

Total products

-

- {items.length} -

-
- -
-

Low stock

-

- {lowStockCount} -

-
- -
-

Out of stock

-

- {outOfStockCount} -

-
-
- - {inventoryQuery.isFetching ? ( -

Refreshing inventory...

- ) : null} - - -
- ); -} \ No newline at end of file diff --git a/orders-frontend/src/components/dashboard/OrderHistoryView.tsx b/orders-frontend/src/components/dashboard/OrderHistoryView.tsx deleted file mode 100644 index cc69bc5..0000000 --- a/orders-frontend/src/components/dashboard/OrderHistoryView.tsx +++ /dev/null @@ -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({ - status: "", - from: "", - to: "", - }); - - const ordersQuery = useOrders(filters); - - return ( -
- - - {ordersQuery.isLoading ? ( - - ) : null} - - {ordersQuery.isError ? ( - ordersQuery.refetch()} - /> - ) : null} - - {!ordersQuery.isLoading && - !ordersQuery.isError && - ordersQuery.data?.items.length === 0 ? ( - - ) : null} - - {!ordersQuery.isLoading && - !ordersQuery.isError && - ordersQuery.data && - ordersQuery.data.items.length > 0 ? ( - <> - {ordersQuery.isFetching ? ( -

Refreshing order history...

- ) : null} - - - -

- Showing {ordersQuery.data.items.length} of{" "} - {ordersQuery.data.totalItems} orders. -

- - ) : null} -
- ); -} \ No newline at end of file diff --git a/orders-frontend/src/components/data-table.tsx b/orders-frontend/src/components/data-table.tsx new file mode 100644 index 0000000..a1b224b --- /dev/null +++ b/orders-frontend/src/components/data-table.tsx @@ -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 ( + + ) +} + +const columns: ColumnDef>[] = [ + { + id: "drag", + header: () => null, + cell: ({ row }) => , + }, + { + id: "select", + header: ({ table }) => ( +
+ table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> +
+ ), + cell: ({ row }) => ( +
+ row.toggleSelected(!!value)} + aria-label="Select row" + /> +
+ ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "header", + header: "Header", + cell: ({ row }) => { + return + }, + enableHiding: false, + }, + { + accessorKey: "type", + header: "Section Type", + cell: ({ row }) => ( +
+ + {row.original.type} + +
+ ), + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => ( + + {row.original.status === "Done" ? ( + + ) : ( + + )} + {row.original.status} + + ), + }, + { + accessorKey: "target", + header: () =>
Target
, + cell: ({ row }) => ( +
{ + e.preventDefault() + toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), { + loading: `Saving ${row.original.header}`, + success: "Done", + error: "Error", + }) + }} + > + + +
+ ), + }, + { + accessorKey: "limit", + header: () =>
Limit
, + cell: ({ row }) => ( +
{ + e.preventDefault() + toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), { + loading: `Saving ${row.original.header}`, + success: "Done", + error: "Error", + }) + }} + > + + +
+ ), + }, + { + accessorKey: "reviewer", + header: "Reviewer", + cell: ({ row }) => { + const isAssigned = row.original.reviewer !== "Assign reviewer" + + if (isAssigned) { + return row.original.reviewer + } + + return ( + <> + + + + ) + }, + }, + { + id: "actions", + cell: () => ( + + + + + + Edit + Make a copy + Favorite + + Delete + + + ), + }, +] + +function DraggableRow({ row }: { row: Row> }) { + const { transform, transition, setNodeRef, isDragging } = useSortable({ + id: row.original.id, + }) + + return ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) +} + +export function DataTable({ + data: initialData, +}: { + data: z.infer[] +}) { + const [data, setData] = React.useState(() => initialData) + const [rowSelection, setRowSelection] = React.useState({}) + const [columnVisibility, setColumnVisibility] = + React.useState({}) + const [columnFilters, setColumnFilters] = React.useState( + [] + ) + const [sorting, setSorting] = React.useState([]) + 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( + () => 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 ( + +
+ + + + Outline + + Past Performance 3 + + + Key Personnel 2 + + Focus Documents + +
+ + + + + + {table + .getAllColumns() + .filter( + (column) => + typeof column.accessorFn !== "undefined" && + column.getCanHide() + ) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ) + })} + + + +
+
+ +
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + + {table.getRowModel().rows.map((row) => ( + + ))} + + ) : ( + + + No results. + + + )} + +
+
+
+
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s) selected. +
+
+
+ + +
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+
+ +
+
+ +
+
+ +
+
+
+ ) +} + +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 }) { + const isMobile = useIsMobile() + + return ( + + + + + + + {item.header} + + Showing total visitors for the last 6 months + + +
+ {!isMobile && ( + <> + + + + value.slice(0, 3)} + hide + /> + } + /> + + + + + +
+
+ Trending up by 5.2% this month{" "} + +
+
+ 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. +
+
+ + + )} +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + + + + + +
+
+ ) +} diff --git a/orders-frontend/src/components/inventory/InventoryStatusBadge.tsx b/orders-frontend/src/components/inventory/InventoryStatusBadge.tsx deleted file mode 100644 index fc14999..0000000 --- a/orders-frontend/src/components/inventory/InventoryStatusBadge.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import {cn} from "#/lib/utils"; -import type {InventoryStatus} from "#/types/inventory"; - -type Props = { - status: InventoryStatus; -}; - -const labelMap: Record = { - IN_STOCK: "In stock", - LOW_STOCK: "Low stock", - OUT_OF_STOCK: "Out of stock", -}; - -export function InventoryStatusBadge({status}: Props) { - return ( - - {labelMap[status]} - - ); -} \ No newline at end of file diff --git a/orders-frontend/src/components/inventory/InventoryTable.tsx b/orders-frontend/src/components/inventory/InventoryTable.tsx deleted file mode 100644 index 064721c..0000000 --- a/orders-frontend/src/components/inventory/InventoryTable.tsx +++ /dev/null @@ -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 ( -
- - - - - - - - - - - - - - - {items.map((item) => ( - - - - - - - - - - ))} - -
ProductSKUAvailableReservedReorder LevelStatusUpdated
- {item.productName} - {item.sku} - {item.availableQuantity} - - {item.reservedQuantity} - - {item.reorderLevel} - - - - {formatDate(item.updatedAt)} -
-
- ); -} \ No newline at end of file diff --git a/orders-frontend/src/components/login-form.tsx b/orders-frontend/src/components/login-form.tsx new file mode 100644 index 0000000..880cdcc --- /dev/null +++ b/orders-frontend/src/components/login-form.tsx @@ -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) => void + onSignupClick: () => void +} + +export function LoginForm({ + className, + username, + password, + error, + pending = false, + onUsernameChange, + onPasswordChange, + onSubmit, + onSignupClick, + ...props +}: LoginFormProps) { + return ( +
+ + + Sign in to Cymelle + + Use your username and password to continue. + + + +
+ + + Username + onUsernameChange(event.target.value)} + required + /> + + +
+ Password +
+ onPasswordChange(event.target.value)} + required + /> +
+ {error ? {error} : null} + + + + Don't have an account?{" "} + + + +
+
+
+
+
+ ) +} diff --git a/orders-frontend/src/components/nav-documents.tsx b/orders-frontend/src/components/nav-documents.tsx new file mode 100644 index 0000000..77a7d76 --- /dev/null +++ b/orders-frontend/src/components/nav-documents.tsx @@ -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 ( + + Documents + + {items.map((item) => ( + + + + + {item.name} + + + + + + + More + + + + + + Open + + + + Share + + + + + Delete + + + + + ))} + + + + More + + + + + ) +} diff --git a/orders-frontend/src/components/nav-main.tsx b/orders-frontend/src/components/nav-main.tsx new file mode 100644 index 0000000..e83915a --- /dev/null +++ b/orders-frontend/src/components/nav-main.tsx @@ -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(null) + const [openSections, setOpenSections] = useState>(() => { + 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 ( + + Commerce + + {items.map((item) => ( + { + setOpenSections((current) => { + const next = new Set(current) + if (open) { + next.add(item.title) + } else { + next.delete(item.title) + } + return next + }) + }} + className="group/collapsible" + > + + + + {item.icon && } + {item.title} + + + + + + {item.items?.map((subItem) => ( + + + + {subItem.title} + + + + ))} + + + + + ))} + + + ) +} + +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 === "/") +} diff --git a/orders-frontend/src/components/nav-projects.tsx b/orders-frontend/src/components/nav-projects.tsx new file mode 100644 index 0000000..2cb051a --- /dev/null +++ b/orders-frontend/src/components/nav-projects.tsx @@ -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 ( + + Shortcuts + + {projects.map((item) => ( + + + + + {item.name} + + + + + + + More + + + + + + + Open + + + + + + ))} + + + + More + + + + + ) +} diff --git a/orders-frontend/src/components/nav-secondary.tsx b/orders-frontend/src/components/nav-secondary.tsx new file mode 100644 index 0000000..28dbe9a --- /dev/null +++ b/orders-frontend/src/components/nav-secondary.tsx @@ -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) { + return ( + + + + {items.map((item) => ( + + + + + {item.title} + + + + ))} + + + + ) +} diff --git a/orders-frontend/src/components/nav-user.tsx b/orders-frontend/src/components/nav-user.tsx new file mode 100644 index 0000000..549cc8c --- /dev/null +++ b/orders-frontend/src/components/nav-user.tsx @@ -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 ( + + + + + + + + {initials} + +
+ {user.name} + {user.email} +
+ +
+
+ + +
+ + + {initials} + +
+ {user.name} + {user.email} +
+
+
+ + + + + Account session + + + + { + event.preventDefault() + void onLogout?.() + }} + > + + Log out + +
+
+
+
+ ) +} diff --git a/orders-frontend/src/components/orders/CancelOrderButton.tsx b/orders-frontend/src/components/orders/CancelOrderButton.tsx deleted file mode 100644 index 4174f16..0000000 --- a/orders-frontend/src/components/orders/CancelOrderButton.tsx +++ /dev/null @@ -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 —; - } - - function handleCancel() { - const confirmed = window.confirm( - "Cancel this order? Stock will be rolled back.", - ); - - if (!confirmed) { - return; - } - - cancelMutation.mutate(orderId); - } - - return ( -
- - - {cancelMutation.error ? ( -

- {cancelMutation.error instanceof Error - ? cancelMutation.error.message - : "Failed to cancel order"} -

- ) : null} -
- ); -} \ No newline at end of file diff --git a/orders-frontend/src/components/orders/OrderFilters.tsx b/orders-frontend/src/components/orders/OrderFilters.tsx deleted file mode 100644 index 34b85e4..0000000 --- a/orders-frontend/src/components/orders/OrderFilters.tsx +++ /dev/null @@ -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: Key, - value: OrderFiltersType[Key], - ) { - onChange({ - ...filters, - [key]: value, - }); - } - - return ( -
- - - - - - -
- -
-
- ); -} \ No newline at end of file diff --git a/orders-frontend/src/components/orders/OrderHistoryTable.tsx b/orders-frontend/src/components/orders/OrderHistoryTable.tsx deleted file mode 100644 index c55d7dd..0000000 --- a/orders-frontend/src/components/orders/OrderHistoryTable.tsx +++ /dev/null @@ -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 ( -
- - - - - - - - - - - - - - - - {orders.map((order) => ( - - - - - - - - - - - ))} - -
OrderCustomerStatusItemsTotalCreatedCancelledAction
- {order.id.slice(0, 8)} - - {order.customerName} - - - {order.itemCount} - {formatCurrency(order.totalAmount, order.currency)} - - {formatDate(order.createdAt)} - - {formatDate(order.cancelledAt)} - - -
-
- ); -} \ No newline at end of file diff --git a/orders-frontend/src/components/orders/OrderStatusBadge.tsx b/orders-frontend/src/components/orders/OrderStatusBadge.tsx deleted file mode 100644 index 92842d1..0000000 --- a/orders-frontend/src/components/orders/OrderStatusBadge.tsx +++ /dev/null @@ -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 ( - - {status === "PLACED" ? "Placed" : "Cancelled"} - - ); -} \ No newline at end of file diff --git a/orders-frontend/src/components/section-cards.tsx b/orders-frontend/src/components/section-cards.tsx new file mode 100644 index 0000000..10d55bc --- /dev/null +++ b/orders-frontend/src/components/section-cards.tsx @@ -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 ( +
+ + + Total Revenue + + $1,250.00 + + + + + +12.5% + + + + +
+ Trending up this month +
+
+ Visitors for the last 6 months +
+
+
+ + + New Customers + + 1,234 + + + + + -20% + + + + +
+ Down 20% this period +
+
+ Acquisition needs attention +
+
+
+ + + Active Accounts + + 45,678 + + + + + +12.5% + + + + +
+ Strong user retention +
+
Engagement exceed targets
+
+
+ + + Growth Rate + + 4.5% + + + + + +4.5% + + + + +
+ Steady performance increase +
+
Meets growth projections
+
+
+
+ ) +} diff --git a/orders-frontend/src/components/signup-form.tsx b/orders-frontend/src/components/signup-form.tsx new file mode 100644 index 0000000..3fade76 --- /dev/null +++ b/orders-frontend/src/components/signup-form.tsx @@ -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 & { + 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) => void + onLoginClick: () => void +} + +export function SignupForm({ + displayName, + username, + password, + confirmPassword, + error, + pending = false, + onDisplayNameChange, + onUsernameChange, + onPasswordChange, + onConfirmPasswordChange, + onSubmit, + onLoginClick, + ...props +}: SignupFormProps) { + return ( + + + Create an account + + Create a customer account to place orders and request rides. + + + +
+ + + Display name + onDisplayNameChange(event.target.value)} + required + /> + + + Username + onUsernameChange(event.target.value)} + required + /> + + This is what you will use when signing in. + + + + Password + onPasswordChange(event.target.value)} + required + /> + + Must be at least 8 characters long. + + + + + Confirm Password + + onConfirmPasswordChange(event.target.value)} + required + /> + Please confirm your password. + + {error ? {error} : null} + + + + + Already have an account?{" "} + + + + + +
+
+
+ ) +} diff --git a/orders-frontend/src/components/site-header.tsx b/orders-frontend/src/components/site-header.tsx new file mode 100644 index 0000000..e116ef2 --- /dev/null +++ b/orders-frontend/src/components/site-header.tsx @@ -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 ( +
+
+ + +

{title}

+
+ + {showCart ? ( + + ) : null} + +
+
+
+ ) +} diff --git a/orders-frontend/src/components/store/AuthDialog.tsx b/orders-frontend/src/components/store/AuthDialog.tsx new file mode 100644 index 0000000..3fcc35c --- /dev/null +++ b/orders-frontend/src/components/store/AuthDialog.tsx @@ -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 + onRegister: (request: { + username: string + password: string + displayName: string + }) => Promise +} + +export function AuthDialog({ + open, + initialMode, + onOpenChange, + onLogin, + onRegister, +}: AuthDialogProps) { + return ( + + + + + + ) +} + +export function AuthForm({ + initialMode, + onLogin, + onRegister, +}: Omit) { + 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(null) + const [pending, setPending] = useState(false) + + useEffect(() => { + setMode(initialMode) + setError(null) + }, [initialMode]) + + async function handleLogin(event: React.FormEvent) { + 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) { + 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 ( + { + setMode("login") + setError(null) + }} + /> + ) + } + + return ( + { + setMode("register") + setError(null) + }} + /> + ) +} diff --git a/orders-frontend/src/components/store/ProductCard.tsx b/orders-frontend/src/components/store/ProductCard.tsx new file mode 100644 index 0000000..7efc73a --- /dev/null +++ b/orders-frontend/src/components/store/ProductCard.tsx @@ -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 ( + + +
+
+ {product.name} +

{product.sku}

+
+ +
+
+ +

{formatCurrency(product.price, product.currency)}

+

{product.availableQuantity} available

+ {onAddToCart ? ( +
+ + +
+ ) : null} +
+ + + +
+ ); +} diff --git a/orders-frontend/src/components/store/ProductGrid.tsx b/orders-frontend/src/components/store/ProductGrid.tsx new file mode 100644 index 0000000..92166cb --- /dev/null +++ b/orders-frontend/src/components/store/ProductGrid.tsx @@ -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 ; + } + + if (products.length === 0) { + return ( +
+ No products available. +
+ ); + } + + return ( +
+ {products.map((product) => ( + + ))} +
+ ); +} diff --git a/orders-frontend/src/components/store/QuantityStepper.tsx b/orders-frontend/src/components/store/QuantityStepper.tsx new file mode 100644 index 0000000..1bc8c4d --- /dev/null +++ b/orders-frontend/src/components/store/QuantityStepper.tsx @@ -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 ( +
+ + setQuantity(Number(event.target.value))} + className="h-9 rounded-none border-y-0 px-2 text-center shadow-none" + /> + +
+ ); +} diff --git a/orders-frontend/src/components/store/SiteHeader.tsx b/orders-frontend/src/components/store/SiteHeader.tsx new file mode 100644 index 0000000..ba4ed56 --- /dev/null +++ b/orders-frontend/src/components/store/SiteHeader.tsx @@ -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 ( +
+
+
+ Cymelle +

Commerce, delivery, and rides

+
+
+ + + {user ? ( + <> +
+ + {user.displayName} +
+ + + ) : ( + <> + + + + )} +
+
+
+ ); +} diff --git a/orders-frontend/src/components/store/StoreHome.tsx b/orders-frontend/src/components/store/StoreHome.tsx new file mode 100644 index 0000000..3c30d07 --- /dev/null +++ b/orders-frontend/src/components/store/StoreHome.tsx @@ -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 ( +
+ setAuthMode("login")} + onRegister={() => setAuthMode("register")} + onLogout={logout} + /> +
+
+

Store

+

Shop Cymelle products

+
+ +
+ !open && setAuthMode(null)} + onLogin={handleLogin} + onRegister={handleRegister} + /> +
+ ); +} diff --git a/orders-frontend/src/components/team-switcher.tsx b/orders-frontend/src/components/team-switcher.tsx new file mode 100644 index 0000000..31f76cf --- /dev/null +++ b/orders-frontend/src/components/team-switcher.tsx @@ -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 ( + + + + + +
+ +
+
+ {activeTeam.name} + {activeTeam.plan} +
+ +
+
+ + + Workspace + + {teams.map((team, index) => ( + setActiveTeam(team)} + className="gap-2 p-2" + > +
+ +
+ {team.name} +
+ ))} +
+
+
+
+ ) +} diff --git a/orders-frontend/src/components/theme-provider.tsx b/orders-frontend/src/components/theme-provider.tsx new file mode 100644 index 0000000..83c483c --- /dev/null +++ b/orders-frontend/src/components/theme-provider.tsx @@ -0,0 +1,14 @@ +import {ThemeProvider as NextThemesProvider} from "next-themes"; + +export function ThemeProvider({children}: {children: React.ReactNode}) { + return ( + + {children} + + ); +} diff --git a/orders-frontend/src/components/theme-toggle.tsx b/orders-frontend/src/components/theme-toggle.tsx new file mode 100644 index 0000000..81a1ffc --- /dev/null +++ b/orders-frontend/src/components/theme-toggle.tsx @@ -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 ( + + + + + + setTheme("light")}> + + Light + + setTheme("dark")}> + + Dark + + setTheme("system")}> + + System + + + + ); +} diff --git a/orders-frontend/src/components/ui/LoadingState.tsx b/orders-frontend/src/components/ui/LoadingState.tsx index 45b1845..9877647 100644 --- a/orders-frontend/src/components/ui/LoadingState.tsx +++ b/orders-frontend/src/components/ui/LoadingState.tsx @@ -1,18 +1,21 @@ type LoadingStateProps = { title?: string; - description?: string; }; export function LoadingState({ title = "Loading", - description = "Fetching the latest data...", }: LoadingStateProps) { return ( -
-
-

{title}

-

{description}

+
+
+
+
+
+
+
+
+
+
); -} \ No newline at end of file +} diff --git a/orders-frontend/src/components/ui/avatar.tsx b/orders-frontend/src/components/ui/avatar.tsx new file mode 100644 index 0000000..3a515d7 --- /dev/null +++ b/orders-frontend/src/components/ui/avatar.tsx @@ -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 & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + 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 ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
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, +} diff --git a/orders-frontend/src/components/ui/badge.tsx b/orders-frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..cd79adb --- /dev/null +++ b/orders-frontend/src/components/ui/badge.tsx @@ -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 & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/orders-frontend/src/components/ui/breadcrumb.tsx b/orders-frontend/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..18d5124 --- /dev/null +++ b/orders-frontend/src/components/ui/breadcrumb.tsx @@ -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