Add project
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.cs]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
max_line_length = 160
|
||||||
|
|
||||||
|
# Formatting
|
||||||
|
csharp_indent_case_contents = true
|
||||||
|
csharp_indent_switch_labels = true
|
||||||
|
csharp_indent_labels = one_less_than_current
|
||||||
|
csharp_indent_block_contents = true
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/Web/Web.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${DB_USER}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASS}
|
||||||
|
POSTGRES_DB: ${DB_NAME}
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -d ${DB_NAME} -U ${DB_USER}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./src/Web
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: web-api
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5000:80"
|
||||||
|
environment:
|
||||||
|
- ASPNETCORE_ENVIRONMENT=Development
|
||||||
|
- ConnectionStrings__DbContext=Host=postgres;Port=5432;Database=${DB_NAME};Username=${DB_USER};Password=${DB_PASS}
|
||||||
|
- ASPNETCORE_URLS=http://+:80
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Web.Controllers;
|
||||||
|
|
||||||
|
public class HomeController : Controller
|
||||||
|
{
|
||||||
|
public IActionResult Index() =>
|
||||||
|
RedirectToAction("Create", "OrdersPage");
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Web.Models;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Web.Services;
|
||||||
|
|
||||||
|
namespace Web.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class OrdersController(IOrdersService os) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<IEnumerable<Order>>> GetOrders()
|
||||||
|
{
|
||||||
|
Order[] orders = await os.GetOrdersAsync();
|
||||||
|
return Ok(orders);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<ActionResult<Order>> GetOrder(Guid id)
|
||||||
|
{
|
||||||
|
Order? order = await os.GetOrderAsync(id);
|
||||||
|
|
||||||
|
if (order is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
return Ok(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult<Order>> CreateOrder(CreateOrderDto dto)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return BadRequest(ModelState);
|
||||||
|
|
||||||
|
Order order = await os.CreateOrderAsync(dto);
|
||||||
|
return Ok(order);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Web.Models;
|
||||||
|
using Web.Services;
|
||||||
|
|
||||||
|
namespace Web.Controllers;
|
||||||
|
|
||||||
|
public class OrdersPageController(IOrdersService ordersService) : Controller
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public IActionResult Create() =>
|
||||||
|
View(new CreateOrderViewModel());
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> Create(CreateOrderViewModel model)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return View(model);
|
||||||
|
|
||||||
|
CreateOrderDto dto = new()
|
||||||
|
{
|
||||||
|
SenderCity = model.SenderCity,
|
||||||
|
SenderAddress = model.SenderAddress,
|
||||||
|
RecipientCity = model.RecipientCity,
|
||||||
|
RecipientAddress = model.RecipientAddress,
|
||||||
|
CargoWeight = model.CargoWeight,
|
||||||
|
PickupDate = new DateTimeOffset(model.PickupDate.Date, TimeSpan.Zero)
|
||||||
|
};
|
||||||
|
|
||||||
|
Order order = await ordersService.CreateOrderAsync(dto);
|
||||||
|
return RedirectToAction(nameof(Details), new { id = order.OrderNumber });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> List()
|
||||||
|
{
|
||||||
|
Order[] orders = await ordersService.GetOrdersAsync();
|
||||||
|
return View(orders);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Details(Guid id)
|
||||||
|
{
|
||||||
|
Order? order = await ordersService.GetOrderAsync(id);
|
||||||
|
if (order is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
return View(order);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Web.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Web.Data;
|
||||||
|
|
||||||
|
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
|
||||||
|
{
|
||||||
|
public DbSet<Order> Orders { get; set; } = null!;
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Order>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.OrderNumber);
|
||||||
|
|
||||||
|
entity.Property(e => e.SenderCity)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100);
|
||||||
|
|
||||||
|
entity.Property(e => e.SenderAddress)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200);
|
||||||
|
|
||||||
|
entity.Property(e => e.RecipientCity)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100);
|
||||||
|
|
||||||
|
entity.Property(e => e.RecipientAddress)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200);
|
||||||
|
|
||||||
|
entity.Property(e => e.CargoWeight)
|
||||||
|
.IsRequired()
|
||||||
|
.HasPrecision(10, 2);
|
||||||
|
|
||||||
|
entity.Property(e => e.PickupDate)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
entity.Property(e => e.CreatedAt)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY ["Web.csproj", "."]
|
||||||
|
RUN dotnet restore "Web.csproj"
|
||||||
|
COPY . .
|
||||||
|
RUN dotnet publish "Web.csproj" -c Release -o /app/publish
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/publish .
|
||||||
|
ENTRYPOINT ["dotnet", "Web.dll"]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Web.Data;
|
||||||
|
using Web.Services;
|
||||||
|
namespace Web.Extensions;
|
||||||
|
|
||||||
|
internal static class IServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddInternalServices(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddScoped<IOrdersService, OrdersService>();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddDbAccess(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
string connectionString = configuration.GetConnectionString("DbContext")
|
||||||
|
?? throw new InvalidOperationException("DB connection string is not set");
|
||||||
|
services.AddDbContext<AppDbContext>(opts => opts.UseNpgsql(connectionString));
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using Web.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Web.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260706073156_Init")]
|
||||||
|
partial class Init
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Web.Models.Order", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("OrderNumber")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("CargoWeight")
|
||||||
|
.HasPrecision(10, 2)
|
||||||
|
.HasColumnType("numeric(10,2)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("PickupDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("RecipientAddress")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("RecipientCity")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("SenderAddress")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("SenderCity")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.HasKey("OrderNumber");
|
||||||
|
|
||||||
|
b.ToTable("Orders");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Web.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Init : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Orders",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
OrderNumber = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
SenderCity = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
SenderAddress = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
RecipientCity = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
RecipientAddress = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
CargoWeight = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false),
|
||||||
|
PickupDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Orders", x => x.OrderNumber);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Orders");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using Web.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Web.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Web.Models.Order", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("OrderNumber")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("CargoWeight")
|
||||||
|
.HasPrecision(10, 2)
|
||||||
|
.HasColumnType("numeric(10,2)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("PickupDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("RecipientAddress")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("RecipientCity")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("SenderAddress")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("SenderCity")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.HasKey("OrderNumber");
|
||||||
|
|
||||||
|
b.ToTable("Orders");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Web.Models;
|
||||||
|
|
||||||
|
public class CreateOrderDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string SenderCity { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(200)]
|
||||||
|
public string SenderAddress { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string RecipientCity { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(200)]
|
||||||
|
public string RecipientAddress { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Range(0.1, 100, ErrorMessage = "Weight must be between 0.1 and 100 kg")]
|
||||||
|
public decimal CargoWeight { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public DateTimeOffset PickupDate { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Web.Models;
|
||||||
|
|
||||||
|
public class CreateOrderViewModel
|
||||||
|
{
|
||||||
|
[Required(ErrorMessage = "Укажите город отправителя")]
|
||||||
|
[MaxLength(100)]
|
||||||
|
[Display(Name = "Город отправителя")]
|
||||||
|
public string SenderCity { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "Укажите адрес отправителя")]
|
||||||
|
[MaxLength(200)]
|
||||||
|
[Display(Name = "Адрес отправителя")]
|
||||||
|
public string SenderAddress { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "Укажите город получателя")]
|
||||||
|
[MaxLength(100)]
|
||||||
|
[Display(Name = "Город получателя")]
|
||||||
|
public string RecipientCity { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "Укажите адрес получателя")]
|
||||||
|
[MaxLength(200)]
|
||||||
|
[Display(Name = "Адрес получателя")]
|
||||||
|
public string RecipientAddress { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "Укажите вес груза")]
|
||||||
|
[Range(0.1, 100, ErrorMessage = "Вес должен быть от 0,1 до 100 кг")]
|
||||||
|
[Display(Name = "Вес груза, кг")]
|
||||||
|
public decimal CargoWeight { get; set; }
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "Укажите дату забора груза")]
|
||||||
|
[DataType(DataType.Date)]
|
||||||
|
[Display(Name = "Дата забора груза")]
|
||||||
|
public DateTime PickupDate { get; set; } = DateTime.Today;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Web.Models;
|
||||||
|
|
||||||
|
public class Order
|
||||||
|
{
|
||||||
|
public Guid OrderNumber { get; set; } = Guid.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string SenderCity { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(200)]
|
||||||
|
public string SenderAddress { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string RecipientCity { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(200)]
|
||||||
|
public string RecipientAddress { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Range(0.1, 100)]
|
||||||
|
public decimal CargoWeight { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public DateTimeOffset PickupDate { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Web.Data;
|
||||||
|
using Web.Extensions;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
var services = builder.Services;
|
||||||
|
var configuration = builder.Configuration;
|
||||||
|
|
||||||
|
services.AddControllersWithViews();
|
||||||
|
services.AddEndpointsApiExplorer();
|
||||||
|
services.AddSwaggerGen();
|
||||||
|
services.AddDbAccess(configuration);
|
||||||
|
services.AddInternalServices();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
dbContext.Database.Migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseRouting();
|
||||||
|
|
||||||
|
app.UseStaticFiles();
|
||||||
|
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
|
||||||
|
app.MapControllerRoute(
|
||||||
|
name: "default",
|
||||||
|
pattern: "{controller=Home}/{action=Index}/{id?}");
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5280",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||||
|
using Web.Data;
|
||||||
|
using Web.Models;
|
||||||
|
|
||||||
|
namespace Web.Services;
|
||||||
|
|
||||||
|
public interface IOrdersService
|
||||||
|
{
|
||||||
|
Task<Order?> GetOrderAsync(Guid id);
|
||||||
|
Task<Order[]> GetOrdersAsync();
|
||||||
|
|
||||||
|
Task<Order> CreateOrderAsync(CreateOrderDto dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class OrdersService(AppDbContext dbCtx) : IOrdersService
|
||||||
|
{
|
||||||
|
public async Task<Order?> GetOrderAsync(Guid id)
|
||||||
|
{
|
||||||
|
Order? order = await dbCtx.Orders.FindAsync(id);
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Order[]> GetOrdersAsync()
|
||||||
|
{
|
||||||
|
Order[] orders =
|
||||||
|
await dbCtx.Orders
|
||||||
|
.OrderByDescending(o => o.CreatedAt)
|
||||||
|
.ToArrayAsync();
|
||||||
|
return orders;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Order> CreateOrderAsync(CreateOrderDto dto)
|
||||||
|
{
|
||||||
|
Order order = new()
|
||||||
|
{
|
||||||
|
OrderNumber = Guid.NewGuid(),
|
||||||
|
SenderCity = dto.SenderCity,
|
||||||
|
SenderAddress = dto.SenderAddress,
|
||||||
|
RecipientCity = dto.RecipientCity,
|
||||||
|
RecipientAddress = dto.RecipientAddress,
|
||||||
|
CargoWeight = dto.CargoWeight,
|
||||||
|
PickupDate = dto.PickupDate,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
EntityEntry<Order> created = dbCtx.Orders.Add(order);
|
||||||
|
await dbCtx.SaveChangesAsync();
|
||||||
|
return created.Entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
@model CreateOrderViewModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Новый заказ";
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h1>Форма создания заказа</h1>
|
||||||
|
<form asp-action="Create" method="post" class="form" novalidate>
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
|
||||||
|
<div class="form__grid">
|
||||||
|
<div class="field">
|
||||||
|
<label asp-for="SenderCity"></label>
|
||||||
|
<input asp-for="SenderCity" />
|
||||||
|
<span asp-validation-for="SenderCity" class="field__error"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label asp-for="SenderAddress"></label>
|
||||||
|
<input asp-for="SenderAddress" />
|
||||||
|
<span asp-validation-for="SenderAddress" class="field__error"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label asp-for="RecipientCity"></label>
|
||||||
|
<input asp-for="RecipientCity" />
|
||||||
|
<span asp-validation-for="RecipientCity" class="field__error"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label asp-for="RecipientAddress"></label>
|
||||||
|
<input asp-for="RecipientAddress" />
|
||||||
|
<span asp-validation-for="RecipientAddress" class="field__error"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label asp-for="CargoWeight"></label>
|
||||||
|
<input asp-for="CargoWeight" type="number" min="0.1" max="100" step="0.1" />
|
||||||
|
<span asp-validation-for="CargoWeight" class="field__error"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label asp-for="PickupDate"></label>
|
||||||
|
<input asp-for="PickupDate" type="date" />
|
||||||
|
<span asp-validation-for="PickupDate" class="field__error"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form__actions">
|
||||||
|
<button type="submit" class="button">Создать заказ</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
@model Order
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Просмотр заказа";
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>Просмотр заказа</h1>
|
||||||
|
<p class="hint">Режим только для чтения</p>
|
||||||
|
</div>
|
||||||
|
<a class="button button--secondary" href="@Url.Action("List", "OrdersPage")">К списку</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="details">
|
||||||
|
<div class="details__item">
|
||||||
|
<dt>Номер заказа</dt>
|
||||||
|
<dd>@Model.OrderNumber</dd>
|
||||||
|
</div>
|
||||||
|
<div class="details__item">
|
||||||
|
<dt>Город отправителя</dt>
|
||||||
|
<dd>@Model.SenderCity</dd>
|
||||||
|
</div>
|
||||||
|
<div class="details__item">
|
||||||
|
<dt>Адрес отправителя</dt>
|
||||||
|
<dd>@Model.SenderAddress</dd>
|
||||||
|
</div>
|
||||||
|
<div class="details__item">
|
||||||
|
<dt>Город получателя</dt>
|
||||||
|
<dd>@Model.RecipientCity</dd>
|
||||||
|
</div>
|
||||||
|
<div class="details__item">
|
||||||
|
<dt>Адрес получателя</dt>
|
||||||
|
<dd>@Model.RecipientAddress</dd>
|
||||||
|
</div>
|
||||||
|
<div class="details__item">
|
||||||
|
<dt>Вес груза</dt>
|
||||||
|
<dd>@Model.CargoWeight.ToString("0.##") кг</dd>
|
||||||
|
</div>
|
||||||
|
<div class="details__item">
|
||||||
|
<dt>Дата забора груза</dt>
|
||||||
|
<dd>@Model.PickupDate.ToString("dd.MM.yyyy")</dd>
|
||||||
|
</div>
|
||||||
|
<div class="details__item">
|
||||||
|
<dt>Дата создания</dt>
|
||||||
|
<dd>@Model.CreatedAt.ToString("dd.MM.yyyy HH:mm")</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
@model Order[]
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Список заказов";
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>Список заказов</h1>
|
||||||
|
<p class="hint">Нажмите на номер заказа, чтобы открыть подробности</p>
|
||||||
|
</div>
|
||||||
|
<a class="button button--secondary" href="@Url.Action("Create", "OrdersPage")">Создать заказ</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Model.Length == 0)
|
||||||
|
{
|
||||||
|
<p class="empty">Заказов пока нет.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Номер заказа</th>
|
||||||
|
<th>Отправитель</th>
|
||||||
|
<th>Получатель</th>
|
||||||
|
<th>Вес</th>
|
||||||
|
<th>Дата забора</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (Order order in Model)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="@Url.Action("Details", "OrdersPage", new { id = order.OrderNumber })">
|
||||||
|
@order.OrderNumber
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>@order.SenderCity</strong>
|
||||||
|
<span>@order.SenderAddress</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>@order.RecipientCity</strong>
|
||||||
|
<span>@order.RecipientAddress</span>
|
||||||
|
</td>
|
||||||
|
<td>@order.CargoWeight.ToString("0.##") кг</td>
|
||||||
|
<td>@order.PickupDate.ToString("dd.MM.yyyy")</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>@ViewData["Title"] — Versta test</title>
|
||||||
|
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="header">
|
||||||
|
<div class="container header__inner">
|
||||||
|
<a class="brand" href="/">Versta test</a>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="@Url.Action("Create", "OrdersPage")">Новый заказ</a>
|
||||||
|
<a href="@Url.Action("List", "OrdersPage")">Список заказов</a>
|
||||||
|
<a href="/swagger">API</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container main">
|
||||||
|
@RenderBody()
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="~/js/site.js" asp-append-version="true"></script>
|
||||||
|
@await RenderSectionAsync("Scripts", required: false)
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
@using Web.Models
|
||||||
|
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@{
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<EnableOpenApiSourceGeneration>false</EnableOpenApiSourceGeneration>
|
||||||
|
<OpenApiGenerateXmlComments>false</OpenApiGenerateXmlComments>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore.Swagger" Version="10.2.3" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.2.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DbContext": "Host=localhost;Port=5432;Database=versta-db;Username=versta-user;Password=versta"
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #1a1a1a;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: min(960px, calc(100% - 2rem));
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-weight: 700;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
color: #374151;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a:hover {
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint,
|
||||||
|
.empty {
|
||||||
|
margin: 0;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input {
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus {
|
||||||
|
outline: 2px solid #93c5fd;
|
||||||
|
border-color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__error {
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form__actions {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #1d4ed8;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--secondary {
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td strong {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td span {
|
||||||
|
display: block;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details__item {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details dt {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details dd {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.form__grid,
|
||||||
|
.details,
|
||||||
|
.page-header,
|
||||||
|
.header__inner {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
document.querySelectorAll('form[novalidate]').forEach((form) => {
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
if (!form.checkValidity()) {
|
||||||
|
event.preventDefault();
|
||||||
|
form.reportValidity();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user