diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8107ecb --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.env b/.env new file mode 100644 index 0000000..7905272 --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +DB_NAME=versta-db +DB_USER=versta-user +DB_PASS=versta diff --git a/VerstaTest.slnx b/VerstaTest.slnx new file mode 100644 index 0000000..744a9c8 --- /dev/null +++ b/VerstaTest.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f914adf --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/src/Web/Controllers/HomeController.cs b/src/Web/Controllers/HomeController.cs new file mode 100644 index 0000000..35fdb3b --- /dev/null +++ b/src/Web/Controllers/HomeController.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Web.Controllers; + +public class HomeController : Controller +{ + public IActionResult Index() => + RedirectToAction("Create", "OrdersPage"); +} diff --git a/src/Web/Controllers/OrdersController.cs b/src/Web/Controllers/OrdersController.cs new file mode 100644 index 0000000..48e54db --- /dev/null +++ b/src/Web/Controllers/OrdersController.cs @@ -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>> GetOrders() + { + Order[] orders = await os.GetOrdersAsync(); + return Ok(orders); + } + + [HttpGet("{id}")] + public async Task> GetOrder(Guid id) + { + Order? order = await os.GetOrderAsync(id); + + if (order is null) + return NotFound(); + + return Ok(order); + } + + [HttpPost] + public async Task> CreateOrder(CreateOrderDto dto) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + Order order = await os.CreateOrderAsync(dto); + return Ok(order); + } +} diff --git a/src/Web/Controllers/OrdersPageController.cs b/src/Web/Controllers/OrdersPageController.cs new file mode 100644 index 0000000..bc69fa2 --- /dev/null +++ b/src/Web/Controllers/OrdersPageController.cs @@ -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 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 List() + { + Order[] orders = await ordersService.GetOrdersAsync(); + return View(orders); + } + + [HttpGet] + public async Task Details(Guid id) + { + Order? order = await ordersService.GetOrderAsync(id); + if (order is null) + return NotFound(); + + return View(order); + } +} diff --git a/src/Web/Data/AppDbContext.cs b/src/Web/Data/AppDbContext.cs new file mode 100644 index 0000000..6a71de4 --- /dev/null +++ b/src/Web/Data/AppDbContext.cs @@ -0,0 +1,45 @@ +using Web.Models; +using Microsoft.EntityFrameworkCore; + +namespace Web.Data; + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Orders { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(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(); + }); + } +} diff --git a/src/Web/Dockerfile b/src/Web/Dockerfile new file mode 100644 index 0000000..45a685a --- /dev/null +++ b/src/Web/Dockerfile @@ -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"] diff --git a/src/Web/Extensions/IServiceCollectionExtensions.cs b/src/Web/Extensions/IServiceCollectionExtensions.cs new file mode 100644 index 0000000..224f883 --- /dev/null +++ b/src/Web/Extensions/IServiceCollectionExtensions.cs @@ -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(); + 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(opts => opts.UseNpgsql(connectionString)); + return services; + } +} diff --git a/src/Web/Migrations/20260706073156_Init.Designer.cs b/src/Web/Migrations/20260706073156_Init.Designer.cs new file mode 100644 index 0000000..f3d5260 --- /dev/null +++ b/src/Web/Migrations/20260706073156_Init.Designer.cs @@ -0,0 +1,71 @@ +// +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 + { + /// + 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("OrderNumber") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CargoWeight") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PickupDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RecipientAddress") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RecipientCity") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SenderAddress") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SenderCity") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("OrderNumber"); + + b.ToTable("Orders"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Web/Migrations/20260706073156_Init.cs b/src/Web/Migrations/20260706073156_Init.cs new file mode 100644 index 0000000..5e2f993 --- /dev/null +++ b/src/Web/Migrations/20260706073156_Init.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Web.Migrations +{ + /// + public partial class Init : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Orders", + columns: table => new + { + OrderNumber = table.Column(type: "uuid", nullable: false), + SenderCity = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + SenderAddress = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + RecipientCity = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + RecipientAddress = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + CargoWeight = table.Column(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false), + PickupDate = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Orders", x => x.OrderNumber); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Orders"); + } + } +} diff --git a/src/Web/Migrations/AppDbContextModelSnapshot.cs b/src/Web/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..8db85ab --- /dev/null +++ b/src/Web/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,68 @@ +// +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("OrderNumber") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CargoWeight") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PickupDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RecipientAddress") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RecipientCity") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SenderAddress") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SenderCity") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("OrderNumber"); + + b.ToTable("Orders"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Web/Models/CreateOrderDto.cs b/src/Web/Models/CreateOrderDto.cs new file mode 100644 index 0000000..e954f95 --- /dev/null +++ b/src/Web/Models/CreateOrderDto.cs @@ -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; } +} diff --git a/src/Web/Models/CreateOrderViewModel.cs b/src/Web/Models/CreateOrderViewModel.cs new file mode 100644 index 0000000..277cdf4 --- /dev/null +++ b/src/Web/Models/CreateOrderViewModel.cs @@ -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; +} diff --git a/src/Web/Models/Order.cs b/src/Web/Models/Order.cs new file mode 100644 index 0000000..31d9413 --- /dev/null +++ b/src/Web/Models/Order.cs @@ -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; +} diff --git a/src/Web/Program.cs b/src/Web/Program.cs new file mode 100644 index 0000000..0fa5cb7 --- /dev/null +++ b/src/Web/Program.cs @@ -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(); + 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(); diff --git a/src/Web/Properties/launchSettings.json b/src/Web/Properties/launchSettings.json new file mode 100644 index 0000000..451b890 --- /dev/null +++ b/src/Web/Properties/launchSettings.json @@ -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" + } + } + } +} \ No newline at end of file diff --git a/src/Web/Services/OrdersService.cs b/src/Web/Services/OrdersService.cs new file mode 100644 index 0000000..da4682f --- /dev/null +++ b/src/Web/Services/OrdersService.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Web.Data; +using Web.Models; + +namespace Web.Services; + +public interface IOrdersService +{ + Task GetOrderAsync(Guid id); + Task GetOrdersAsync(); + + Task CreateOrderAsync(CreateOrderDto dto); +} + +internal class OrdersService(AppDbContext dbCtx) : IOrdersService +{ + public async Task GetOrderAsync(Guid id) + { + Order? order = await dbCtx.Orders.FindAsync(id); + return order; + } + + public async Task GetOrdersAsync() + { + Order[] orders = + await dbCtx.Orders + .OrderByDescending(o => o.CreatedAt) + .ToArrayAsync(); + return orders; + } + + public async Task 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 created = dbCtx.Orders.Add(order); + await dbCtx.SaveChangesAsync(); + return created.Entity; + } +} diff --git a/src/Web/Views/OrdersPage/Create.cshtml b/src/Web/Views/OrdersPage/Create.cshtml new file mode 100644 index 0000000..c177f00 --- /dev/null +++ b/src/Web/Views/OrdersPage/Create.cshtml @@ -0,0 +1,53 @@ +@model CreateOrderViewModel +@{ + ViewData["Title"] = "Новый заказ"; +} + +
+

Форма создания заказа

+
+ @Html.AntiForgeryToken() + +
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+
+ +
+ +
+
+
diff --git a/src/Web/Views/OrdersPage/Details.cshtml b/src/Web/Views/OrdersPage/Details.cshtml new file mode 100644 index 0000000..1472680 --- /dev/null +++ b/src/Web/Views/OrdersPage/Details.cshtml @@ -0,0 +1,49 @@ +@model Order +@{ + ViewData["Title"] = "Просмотр заказа"; +} + +
+ + +
+
+
Номер заказа
+
@Model.OrderNumber
+
+
+
Город отправителя
+
@Model.SenderCity
+
+
+
Адрес отправителя
+
@Model.SenderAddress
+
+
+
Город получателя
+
@Model.RecipientCity
+
+
+
Адрес получателя
+
@Model.RecipientAddress
+
+
+
Вес груза
+
@Model.CargoWeight.ToString("0.##") кг
+
+
+
Дата забора груза
+
@Model.PickupDate.ToString("dd.MM.yyyy")
+
+
+
Дата создания
+
@Model.CreatedAt.ToString("dd.MM.yyyy HH:mm")
+
+
+
\ No newline at end of file diff --git a/src/Web/Views/OrdersPage/List.cshtml b/src/Web/Views/OrdersPage/List.cshtml new file mode 100644 index 0000000..4d9cac5 --- /dev/null +++ b/src/Web/Views/OrdersPage/List.cshtml @@ -0,0 +1,57 @@ +@model Order[] +@{ + ViewData["Title"] = "Список заказов"; +} + +
+ + + @if (Model.Length == 0) + { +

Заказов пока нет.

+ } + else + { +
+ + + + + + + + + + + + @foreach (Order order in Model) + { + + + + + + + + } + +
Номер заказаОтправительПолучательВесДата забора
+ + @order.OrderNumber + + + @order.SenderCity + @order.SenderAddress + + @order.RecipientCity + @order.RecipientAddress + @order.CargoWeight.ToString("0.##") кг@order.PickupDate.ToString("dd.MM.yyyy")
+
+ } +
\ No newline at end of file diff --git a/src/Web/Views/Shared/_Layout.cshtml b/src/Web/Views/Shared/_Layout.cshtml new file mode 100644 index 0000000..1d745cd --- /dev/null +++ b/src/Web/Views/Shared/_Layout.cshtml @@ -0,0 +1,28 @@ + + + + + + @ViewData["Title"] — Versta test + + + +
+ +
+ +
+ @RenderBody() +
+ + + @await RenderSectionAsync("Scripts", required: false) + + diff --git a/src/Web/Views/_ViewImports.cshtml b/src/Web/Views/_ViewImports.cshtml new file mode 100644 index 0000000..52e9207 --- /dev/null +++ b/src/Web/Views/_ViewImports.cshtml @@ -0,0 +1,2 @@ +@using Web.Models +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/Web/Views/_ViewStart.cshtml b/src/Web/Views/_ViewStart.cshtml new file mode 100644 index 0000000..d641c67 --- /dev/null +++ b/src/Web/Views/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} \ No newline at end of file diff --git a/src/Web/Web.csproj b/src/Web/Web.csproj new file mode 100644 index 0000000..08e8f87 --- /dev/null +++ b/src/Web/Web.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + false + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/src/Web/appsettings.json b/src/Web/appsettings.json new file mode 100644 index 0000000..18ead6f --- /dev/null +++ b/src/Web/appsettings.json @@ -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": "*" +} \ No newline at end of file diff --git a/src/Web/wwwroot/css/site.css b/src/Web/wwwroot/css/site.css new file mode 100644 index 0000000..f2dd8be --- /dev/null +++ b/src/Web/wwwroot/css/site.css @@ -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; + } +} diff --git a/src/Web/wwwroot/js/site.js b/src/Web/wwwroot/js/site.js new file mode 100644 index 0000000..8e6a9b5 --- /dev/null +++ b/src/Web/wwwroot/js/site.js @@ -0,0 +1,8 @@ +document.querySelectorAll('form[novalidate]').forEach((form) => { + form.addEventListener('submit', (event) => { + if (!form.checkValidity()) { + event.preventDefault(); + form.reportValidity(); + } + }); +}); \ No newline at end of file