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