UniversalCrudLibrary 1.3.6

dotnet add package UniversalCrudLibrary --version 1.3.6
                    
NuGet\Install-Package UniversalCrudLibrary -Version 1.3.6
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="UniversalCrudLibrary" Version="1.3.6" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="UniversalCrudLibrary" Version="1.3.6" />
                    
Directory.Packages.props
<PackageReference Include="UniversalCrudLibrary" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add UniversalCrudLibrary --version 1.3.6
                    
#r "nuget: UniversalCrudLibrary, 1.3.6"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package UniversalCrudLibrary@1.3.6
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=UniversalCrudLibrary&version=1.3.6
                    
Install as a Cake Addin
#tool nuget:?package=UniversalCrudLibrary&version=1.3.6
                    
Install as a Cake Tool

UniversalCrudLibrary

Универсальная библиотека для быстрого создания CRUD Web-приложений на ASP.NET Core.

Основные библиотеки

Microsoft.EntityFrameworkCore

Microsoft.EntityFrameworkCore.Design

Microsoft.EntityFrameworkCore.Tools

Npgsql.EntityFrameworkCore.PostgreSQL

BCrypt.Net-Next

int - числовой тип данных

string - строковый тип данных

double - числовой с зпятой (для процентов)

float - числовой с запятой

byte - битовый тип (для изображений)

// если в данных таблицы используется только дата без временного пояса использовать параметр
[Column(TypeName = "timestamp without time zone")]
public DateTime? StartDate { get; set; }

// если в другой таблице есть записи где столбцы указывают на данные в одной и тойже таблице
// создаем отдельное навигационное свойство к каждому столбцу
public class Users
{
    public int Id { get; set; }
    public string? FIO { get; set; }
    public string? Phone { get; set; }
    public string? Login { get; set; }
    public string Password { get; set; } = null!;
    public string? Type { get; set; }

    public int? RoleId { get; set; }
    public Role? Role { get; set; } 

    // Заявки, где пользователь выступает мастером
    public List<Requests>? RequestsMaster { get; set; }

    // Заявки, где пользователь выступает клиентом
    public List<Requests>? RequestsClient { get; set; }

}

Модели

    public class Manufacturer
    {
        public int Id { get; set; }
        public string? Name { get; set; } = string.Empty;
        public List<Product>? Products { get; set; } = new List<Product>();
    }

    public class Order
    {
        public int Id { get; set; }
        public string? OrderNumber { get; set; } = string.Empty;    

        [Column(TypeName = "timestamp without time zone")]
        public DateTime OrderDate { get; set; }                    

        [Column(TypeName = "timestamp without time zone")]
        public DateTime? DeliveryDate { get; set; }                
        public int? PickupPointId { get; set; }                     
        public string? UserName { get; set; }                            
        public string? PickupCode { get; set; } = string.Empty;     
        public string? Status { get; set; } = string.Empty;         


        public int? UserId { get; set; }
        public User? User { get; set; }
        public PickupPoint? PickupPoint { get; set; }
        public ICollection<OrderItem> OrderItems { get; set; } = new List<OrderItem>();
    }

    public class OrderItem
    {
        public int Id { get; set; }
        public int? OrderId { get; set; }
        public int? ProductId { get; set; }
        public int Quantity { get; set; }
        public decimal? PriceAtOrder { get; set; } 

        public Order? Order { get; set; }
        public Product? Product { get; set; }
    }

    public class PickupPoint
    {
        public int Id { get; set; }
        public string Address { get; set; } = string.Empty;
        public ICollection<Order> Orders { get; set; } = new List<Order>();
    }

    public class Product
    {
        public int Id { get; set; }
        public string Article { get; set; } = string.Empty;        
        public string? Name { get; set; } = string.Empty;           
        public string? Unit { get; set; } = string.Empty;           
        public decimal? Price { get; set; }                         
        public string? Supplier { get; set; } = string.Empty;       
        public string? Manufacturer { get; set; } = string.Empty;   
        public string? Category { get; set; } = string.Empty;       
        public decimal? Discount { get; set; }                      
        public int? QuantityInStock { get; set; }                   
        public string? Description { get; set; } = string.Empty;    
        public string? ImagePath { get; set; }                     


        public int? SupplierId { get; set; }                             
        public int? ManufacturerId { get; set; }                           

        public Suplier? Suppliers { get; set; } = null!;  
        public Manufacturer? Manufacturers { get; set; } = null!;
        public ICollection<OrderItem> OrderItems { get; set; } = new List<OrderItem>();
    }

    public class Suplier
    {
        public int Id { get; set; }
        public string? Name { get; set; } = string.Empty;
        public List<Product>? Products { get; set; } = new List<Product>();

    }

    public class Role
    {
        public int Id { get; set; }
        public string NameRole { get; set; } = string.Empty; 
        public List<User>? Users { get; set; }
    }

    public class User
    {
        public int Id { get; set; }
        public string? RoleName { get; set; }
        public string FullName { get; set; } = string.Empty;
        public string Login { get; set; } = string.Empty;
        public string Password { get; set; } = string.Empty;  
        public int RoleId { get; set; }
        public Role? Role { get; set; }
    }

Модели DTO

public class UserRegisterDto
    {
        public string FullName { get; set; } = string.Empty;
        public string Login { get; set; } = string.Empty;
        public string Password { get; set; } = string.Empty;
        public int RoleId { get; set; }
    }

    public class LoginDto
    {
        public string Login { get; set; } = string.Empty;
        public string Password { get; set; } = string.Empty;
    }

    public class OrderCreateDto
    {
        public string OrderNumber { get; set; }
        public DateTime OrderDate { get; set; }
        public DateTime? DeliveryDate { get; set; }
        public int? PickupPointId { get; set; }
        public int? UserId { get; set; }
        public string? PickupCode { get; set; }
        public string? Status { get; set; }
        public List<OrderItemDto>? Items { get; set; } = new();
    }

    public class OrderUpdateDto
    {
        public string OrderNumber { get; set; }
        public DateTime OrderDate { get; set; }
        public DateTime? DeliveryDate { get; set; }
        public int PickupPointId { get; set; }
        public int? UserId { get; set; }
        public string PickupCode { get; set; }
        public string Status { get; set; }
        public List<OrderItemDto>? Items { get; set; } = new();
    }

    public class OrderDetailsDto : OrderListDto
    {
        public List<OrderItemDto> Items { get; set; } = new();
    }

    public class OrderItemDto
    {
        public int? ProductId { get; set; }
        public int Quantity { get; set; }
        public decimal? PriceAtOrder { get; set; }
        public string? ProductName { get; set; }
    }

    public class OrderListDto
    {
        public int Id { get; set; }
        public string OrderNumber { get; set; }
        public DateTime OrderDate { get; set; }
        public DateTime? DeliveryDate { get; set; }
        public string Status { get; set; }
        public string PickupCode { get; set; }
        public int? UserId { get; set; }
        public string UserName { get; set; }          
        public int? PickupPointId { get; set; }
        public string PickupPointAddress { get; set; } 

        public int ItemsCount { get; set; }
    }

    public class ProductCreateDto
    {
        public int Id { get; set; }
        public string Article { get; set; }
        public string? Name { get; set; }
        public string? Unit { get; set; }
        public decimal? Price { get; set; }
        public int? SupplierId { get; set; }
        public int? ManufacturerId { get; set; }
        public string? Category { get; set; }
        public decimal? Discount { get; set; }
        public int? QuantityInStock { get; set; }
        public string? Description { get; set; }

    }

    public class ProductUpdateDto : ProductCreateDto { }

Контекс базы данных

public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<User> Users { get; set; }
    public DbSet<Role> Roles { get; set; }
    public DbSet<Product> Products { get; set; }
    public DbSet<Order> Orders { get; set; }
    public DbSet<PickupPoint> PickupPoints { get; set; }
    public DbSet<OrderItem> OrderItems { get; set; }
    public DbSet<Suplier> Supliers { get; set; }
    public DbSet<Manufacturer> Manufacturers { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Уникальный логин
        modelBuilder.Entity<User>().HasIndex(u => u.Login).IsUnique();

        // Настройка точности decimal
        modelBuilder.Entity<Product>().Property(p => p.Price).HasPrecision(18, 2);
        modelBuilder.Entity<Product>().Property(p => p.Discount).HasPrecision(5, 2);
        modelBuilder.Entity<OrderItem>().Property(oi => oi.PriceAtOrder).HasPrecision(18, 2);

        // Связи
        modelBuilder.Entity<Order>()
            .HasOne(o => o.User)
            .WithMany()
            .HasForeignKey(o => o.UserId)
            .OnDelete(DeleteBehavior.Restrict);

        modelBuilder.Entity<Order>()
            .HasOne(o => o.PickupPoint)
            .WithMany(pp => pp.Orders)
            .HasForeignKey(o => o.PickupPointId);

        modelBuilder.Entity<OrderItem>()
            .HasOne(oi => oi.Order)
            .WithMany(o => o.OrderItems)
            .HasForeignKey(oi => oi.OrderId);

        modelBuilder.Entity<OrderItem>()
            .HasOne(oi => oi.Product)
            .WithMany(o => o.OrderItems)
            .HasForeignKey(oi => oi.ProductId);

        modelBuilder.Entity<Order>(en =>
        {
            en.HasKey(o => o.Id);
            en.Property(o => o.Id).UseIdentityColumn();
        });

        modelBuilder.Entity<OrderItem>(en =>
        {
            en.HasKey(o => o.Id);
        });

        modelBuilder.Entity<Product>(en =>
        {
            en.HasKey(p => p.Id);

            en.HasOne(p => p.Suppliers)
              .WithMany(s => s.Products)
              .HasForeignKey(p => p.SupplierId);

            en.HasOne(p => p.Manufacturers)
              .WithMany(m => m.Products)
              .HasForeignKey(p => p.ManufacturerId);
        });

        // Инициализация данных
        modelBuilder.Entity<Role>(entity =>
        {
            entity.HasKey(r => r.Id);

            entity.HasData(
                new Role { Id = 1, NameRole = "Администратор" },
                new Role { Id = 2, NameRole = "Менеджер" },
                new Role { Id = 3, NameRole = "Авторизированный клиент" }
            );
        });

        modelBuilder.Entity<Suplier>(entity =>
        {
            entity.HasKey(s => s.Id);

            entity.HasData(
                new Suplier { Id = 1, Name = "Pikeshop" },
                new Suplier { Id = 2, Name = "Playbig" },
                new Suplier { Id = 3, Name = "Knauf" },
                new Suplier { Id = 4, Name = "CHILITOY" },
                new Suplier { Id = 5, Name = "Vinylon" }
            );
        });

        modelBuilder.Entity<Manufacturer>(entity =>
        {
            entity.HasKey(m => m.Id);

            entity.HasData(
                new Manufacturer { Id = 1, Name = "ABSпластик" },
                new Manufacturer { Id = 2, Name = "BambiniFelici" },
                new Manufacturer { Id = 3, Name = "Junion" }
            );

        });

    }

Создание БД с применнием SQL-скрипта


CREATE TABLE IF NOT EXISTS "Roles" (
    "Id"          SERIAL PRIMARY KEY,
    "NameRole"    TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS "Users" (
    "Id"          SERIAL PRIMARY KEY,
    "RoleName"    TEXT,
    "FullName"    TEXT NOT NULL,
    "Login"       TEXT NOT NULL,
    "Password"    TEXT NOT NULL,
    "RoleId"      INT NOT NULL,
    CONSTRAINT "FK_Users_Roles_RoleId"
        FOREIGN KEY ("RoleId") REFERENCES "Roles"("Id")
        ON DELETE RESTRICT
);
CREATE UNIQUE INDEX IF NOT EXISTS "IX_Users_Login" ON "Users" ("Login");

CREATE TABLE IF NOT EXISTS "Supliers" (
    "Id"          SERIAL PRIMARY KEY,
    "Name"        TEXT
);

CREATE TABLE IF NOT EXISTS "Manufacturers" (
    "Id"          SERIAL PRIMARY KEY,
    "Name"        TEXT
);

CREATE TABLE IF NOT EXISTS "Products" (
    "Id"              SERIAL PRIMARY KEY,
    "Article"         TEXT NOT NULL,
    "Name"            TEXT,
    "Unit"            TEXT,
    "Price"           NUMERIC(18,2),
    "Supplier"        TEXT,
    "Manufacturer"    TEXT,
    "Category"        TEXT,
    "Discount"        NUMERIC(5,2),
    "QuantityInStock" INT,
    "Description"     TEXT,
    "ImagePath"       TEXT,
    "SupplierId"      INT,
    "ManufacturerId"  INT,
    CONSTRAINT "FK_Products_Supliers_SupplierId"
        FOREIGN KEY ("SupplierId") REFERENCES "Supliers"("Id")
        ON DELETE SET NULL,
    CONSTRAINT "FK_Products_Manufacturers_ManufacturerId"
        FOREIGN KEY ("ManufacturerId") REFERENCES "Manufacturers"("Id")
        ON DELETE SET NULL
);

CREATE TABLE IF NOT EXISTS "PickupPoints" (
    "Id"       SERIAL PRIMARY KEY,
    "Address"  TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS "Orders" (
    "Id"              SERIAL PRIMARY KEY,
    "OrderNumber"     TEXT,
    "OrderDate"       TIMESTAMP WITHOUT TIME ZONE NOT NULL,
    "DeliveryDate"    TIMESTAMP WITHOUT TIME ZONE,
    "PickupPointId"   INT,
    "UserName"        TEXT,
    "PickupCode"      TEXT,
    "Status"          TEXT,
    "UserId"          INT,
    CONSTRAINT "FK_Orders_Users_UserId"
        FOREIGN KEY ("UserId") REFERENCES "Users"("Id")
        ON DELETE RESTRICT,
    CONSTRAINT "FK_Orders_PickupPoints_PickupPointId"
        FOREIGN KEY ("PickupPointId") REFERENCES "PickupPoints"("Id")
        ON DELETE SET NULL
);

CREATE TABLE IF NOT EXISTS "OrderItems" (
    "Id"            SERIAL PRIMARY KEY,
    "OrderId"       INT,
    "ProductId"     INT,
    "Quantity"      INT NOT NULL,
    "PriceAtOrder"  NUMERIC(18,2),
    CONSTRAINT "FK_OrderItems_Orders_OrderId"
        FOREIGN KEY ("OrderId") REFERENCES "Orders"("Id")
        ON DELETE CASCADE,
    CONSTRAINT "FK_OrderItems_Products_ProductId"
        FOREIGN KEY ("ProductId") REFERENCES "Products"("Id")
        ON DELETE RESTRICT
);

INSERT INTO "Roles" ("Id", "NameRole")
VALUES
    (1, 'Администратор'),
    (2, 'Менеджер'),
    (3, 'Авторизированный клиент')
ON CONFLICT ("Id") DO NOTHING;

INSERT INTO "Supliers" ("Id", "Name")
VALUES
    (1, 'Pikeshop'),
    (2, 'Playbig'),
    (3, 'Knauf'),
    (4, 'CHILITOY'),
    (5, 'Vinylon')
ON CONFLICT ("Id") DO NOTHING;

INSERT INTO "Manufacturers" ("Id", "Name")
VALUES
    (1, 'ABSпластик'),
    (2, 'BambiniFelici'),
    (3, 'Junion')
ON CONFLICT ("Id") DO NOTHING;

-- Сброс последовательностей (если были ручные вставки с Id)
SELECT setval('"Roles_Id_seq"', (SELECT COALESCE(MAX("Id"),1) FROM "Roles"));
SELECT setval('"Supliers_Id_seq"', (SELECT COALESCE(MAX("Id"),1) FROM "Supliers"));
SELECT setval('"Manufacturers_Id_seq"', (SELECT COALESCE(MAX("Id"),1) FROM "Manufacturers"));

Если добавление записей order может выдовать ошибку при импорте выполнить команду в pgAdmin:

SELECT setval(
    '"Orders_Id_seq"',
    (SELECT MAX("Id") FROM "Orders")
);

Подключение к Базе Данных

    "Conect": "Server=127.0.0.1;DataBase=название_бд;Password=521340;Username=postgres;"
  }

Базовая настройка файла Program.cs

var builder = WebApplication.CreateBuilder(args);
var config = builder.Configuration;

builder.Services.AddRazorPages();
builder.Services.AddControllers(); 
builder.Services.AddDbContext<AppDbContext>(op => op.UseNpgsql(config.GetConnectionString("Conect")));
builder.Services.AddAuthorization();
builder.Services.AddAuthentication("Cookies")
    .AddCookie("Cookies", options =>
    {
        options.LoginPath = "/login.html"; 
        options.AccessDeniedPath = "/access-denied";
    });

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}
foreach (var c in app.Services.GetRequiredService<Microsoft.AspNetCore.Mvc.Infrastructure.IActionDescriptorCollectionProvider>().ActionDescriptors.Items)
{
    Console.WriteLine("Route: " + c.AttributeRouteInfo?.Template);
}

app.UseAuthentication();
app.UseAuthorization();

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();
app.MapControllers(); 
app.UseAuthorization();

app.MapRazorPages();

app.Run();

Отображение страниц при запуске

В файле launchsettings.json в блоках https и http указать параметр: "launchUrl": "login.html"
для отображения страницы при запуске проекта

Команды терминал для создание миграций

cd - войти в директорию

dotnet clean - команда очистки проекта

dotnet build - команда сборки проекта

dotnet ef migrations add <название миграции> - создание миграции

dotnet ef database update - применение миграции к БД

Если миграции не работают из-за отсудствия dotnet ef, можно выполнить команду:

dotnet tool install --global dotnet-ef --version 8.0.0

Примеры контролеров API

  1. авторизация/регистрация
[ApiController]
    [Route("api/account")]
    public class AccountController : ControllerBase
    {
        private readonly AppDbContext _context;

        public AccountController(AppDbContext context) => _context = context;

        [HttpPost("register")]
        public async Task<IActionResult> Register([FromBody] UserRegisterDto dto)
        {
            if (await _context.Users.AnyAsync(u => u.Login == dto.Login))
                return BadRequest("Логин уже существует");

            var role = await _context.Roles.FirstOrDefaultAsync(r => r.Id == dto.RoleId);
            if (role == null)
                return BadRequest("Неверная роль");

            var user = new User
            {
                FullName = dto.FullName,
                Login = dto.Login,
                Password = PasswordHelper.CreatePasswordHash(dto.Password),
                RoleId = dto.RoleId
            };
            _context.Users.Add(user);
            await _context.SaveChangesAsync();
            return Ok("Регистрация успешна");
        }

        [HttpPost("login")]
        public async Task<IActionResult> Login([FromBody] LoginDto dto)
        {
            var user = await _context.Users.Include(u => u.Role)
                .FirstOrDefaultAsync(u => u.Login == dto.Login);

            if (user == null || !PasswordHelper.VerifyPassword(dto.Password, user.Password))
                return Unauthorized("Неверный логин или пароль");

            var claims = new List<Claim>
            {
                new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
                new Claim(ClaimTypes.Name, user.Login),
                new Claim(ClaimTypes.Role, user.Role!.NameRole)
            };
            var identity = new ClaimsIdentity(claims, "Cookies");
            await HttpContext.SignInAsync(new ClaimsPrincipal(identity));

            return Ok(new { role = user.Role.NameRole, userId = user.Id, fullName = user.FullName });
        }

        [HttpPost("logout")]
        public async Task<IActionResult> Logout()
        {
            await HttpContext.SignOutAsync();
            return Ok();
        }
    }

    public static class PasswordHelper
    {
        public static string CreatePasswordHash(string password) => BCrypt.Net.BCrypt.HashPassword(password);
        public static bool VerifyPassword(string password, string hash) => BCrypt.Net.BCrypt.Verify(password, hash);
    }
  1. API заказов
[Route("api/orders")]
    [ApiController]
    [Authorize(Roles = "Менеджер,Администратор")]
    public class OrdersController : ControllerBase
    {
        private readonly AppDbContext _context;

        public OrdersController(AppDbContext context) => _context = context;

        [HttpGet("Get")]
        public async Task<IActionResult> GetAll()
        {
            var orders = await _context.Orders
                .AsNoTracking()
                .Include(o => o.User)
                .Include(o => o.PickupPoint)
                .Include(o => o.OrderItems)
                .Select(o => new OrderListDto
                {
                    Id = o.Id,
                    OrderNumber = o.OrderNumber,
                    OrderDate = o.OrderDate,
                    DeliveryDate = o.DeliveryDate,
                    Status = o.Status,
                    PickupCode = o.PickupCode,
                    UserId = o.UserId,
                    UserName = o.User != null ? o.User.FullName : null,
                    PickupPointId = o.PickupPointId,
                    PickupPointAddress = o.PickupPoint != null ? o.PickupPoint.Address : null,
                    ItemsCount = o.OrderItems.Count
                })
                .ToListAsync();
            return Ok(orders);
        }

        [HttpGet("GetId/{id}")]
        public async Task<IActionResult> GetId(int id)
        {
            var order = await _context.Orders
                .AsNoTracking()
                .Include(o => o.User)
                .Include(o => o.PickupPoint)
                .Include(o => o.OrderItems)
                    .ThenInclude(oi => oi.Product)
                .Where(o => o.Id == id)
                .Select(o => new OrderDetailsDto
                {
                    Id = o.Id,
                    OrderNumber = o.OrderNumber,
                    OrderDate = o.OrderDate,
                    DeliveryDate = o.DeliveryDate,
                    Status = o.Status,
                    PickupCode = o.PickupCode,
                    UserId = o.UserId,
                    UserName = o.User != null ? o.User.FullName : null,
                    PickupPointId = o.PickupPointId,
                    PickupPointAddress = o.PickupPoint != null ? o.PickupPoint.Address : null,
                    Items = o.OrderItems.Select(oi => new OrderItemDto
                    {
                        ProductId = oi.ProductId,
                        Quantity = oi.Quantity,
                        PriceAtOrder = oi.PriceAtOrder,
                        ProductName = oi.Product != null ? oi.Product.Name : null
                    }).ToList()
                })
                .FirstOrDefaultAsync();
            if (order == null) return NotFound();
            return Ok(order);
        }

        [Authorize(Roles = "Администратор")]
        [HttpPost("Post")]
        public async Task<IActionResult> Create([FromBody] OrderCreateDto dto)
        {
            if (!ModelState.IsValid)
                return BadRequest(ModelState);

            var user = await _context.Users.FindAsync(dto.UserId);
            if (user == null) return BadRequest("Клиент не найден");
            var pickup = await _context.PickupPoints.FindAsync(dto.PickupPointId);
            if (pickup == null) return BadRequest("Пункт выдачи не найден");

            var order = new Order
            {
                OrderNumber = dto.OrderNumber,
                OrderDate = DateTime.Now,
                DeliveryDate = dto.DeliveryDate,
                PickupPointId = dto.PickupPointId,
                UserId = dto.UserId,
                PickupCode = dto.PickupCode,
                Status = dto.Status,
                OrderItems = new List<OrderItem>()
            };

            foreach (var itemDto in dto.Items)
            {
                var product = await _context.Products.FindAsync(itemDto.ProductId);
                if (product == null) return BadRequest($"Товар {itemDto.ProductId} не найден");
                order.OrderItems.Add(new OrderItem
                {
                    ProductId = itemDto.ProductId,
                    Quantity = itemDto.Quantity,
                    PriceAtOrder = product.Price
                });
            }

            _context.Orders.Add(order);
            await _context.SaveChangesAsync();
            return CreatedAtAction(nameof(GetId), new { id = order.Id }, new { order.Id });
        }

        [HttpPut("Put/{id}")]
        public async Task<IActionResult> Update(int id, [FromBody] OrderUpdateDto dto)
        {
            var order = await _context.Orders
                .Include(o => o.OrderItems)
                .FirstOrDefaultAsync(o => o.Id == id);
            if (order == null) return NotFound();

            // Обновляем основные поля
            order.OrderNumber = dto.OrderNumber;
            order.OrderDate = dto.OrderDate;
            order.DeliveryDate = dto.DeliveryDate;
            order.PickupPointId = dto.PickupPointId;
            order.UserId = dto.UserId;
            order.PickupCode = dto.PickupCode;
            order.Status = dto.Status;

            // Обновляем список товаров (удаляем старые, добавляем новые)
            _context.OrderItems.RemoveRange(order.OrderItems);
            foreach (var itemDto in dto.Items)
            {
                var product = await _context.Products.FindAsync(itemDto.ProductId);
                if (product == null) return BadRequest($"Товар {itemDto.ProductId} не найден");
                order.OrderItems.Add(new OrderItem
                {
                    ProductId = itemDto.ProductId,
                    Quantity = itemDto.Quantity,
                    PriceAtOrder = product.Price
                });
            }

            await _context.SaveChangesAsync();
            return NoContent();
        }

        [Authorize(Roles = "Администратор")]
        [HttpDelete("Delete{id}")]
        public async Task<IActionResult> Delete(int id)
        {
            var order = await _context.Orders.FindAsync(id);
            if (order == null) return NotFound();
            _context.Orders.Remove(order);
            await _context.SaveChangesAsync();
            return Ok();
        }
    }
  1. API пунктов выдачи
[Route("api/pickuppoints")]
    [ApiController]
    [Authorize(Roles = "Менеджер,Администратор")]
    public class PickupPointsController : ControllerBase
    {
        private readonly AppDbContext _context;
        public PickupPointsController(AppDbContext context) => _context = context;

        [HttpGet]
        public async Task<IActionResult> GetAll()
        {
            var points = await _context.PickupPoints.ToListAsync();
            return Ok(points);
        }
    }
  1. API продукции
[Route("api/products")]
    [ApiController]
    public class ProductsController : ControllerBase
    {
        private readonly AppDbContext _context;
        private readonly IWebHostEnvironment _env;

        public ProductsController(AppDbContext context, IWebHostEnvironment env)
        {
            _context = context;
            _env = env;
        }

        // Публичный метод – для гостей и авторизованных клиентов (без фильтрации/поиска/сортировки)
        [HttpGet("Get")]
        public async Task<IActionResult> Get()
        {
            var products = await _context.Products
                .AsNoTracking()
                .Select(p => new Product
                {
                    Id = p.Id,
                    Article = p.Article,
                    Name = p.Name,
                    Unit = p.Unit,
                    Price = p.Price,
                    Supplier = p.Supplier != null ? p.Suppliers.Name : null,
                    Manufacturer = p.Manufacturer != null ? p.Manufacturers.Name : null,
                    Category = p.Category,
                    Discount = p.Discount,
                    QuantityInStock = p.QuantityInStock,
                    Description = p.Description,
                    ImagePath = p.ImagePath
                })
                .ToListAsync();
            return Ok(products);
        }

        // Метод для менеджера и администратора (с фильтрацией, поиском, сортировкой)
        [HttpGet("GetAll")]
        [Authorize(Roles = "Менеджер,Администратор")]
        public async Task<IActionResult> GetAll([FromQuery] string? search, [FromQuery] int? supplierId,
                                                [FromQuery] string? sortBy, [FromQuery] bool? ascending)
        {
            var query = _context.Products.AsQueryable();

            // Поиск по текстовым полям
            if (!string.IsNullOrWhiteSpace(search))
            {
                query = query.Where(p =>
                    p.Name.Contains(search) ||
                    p.Description.Contains(search) ||
                    p.Manufacturer.Contains(search) ||
                    p.Category.Contains(search) ||
                    p.Supplier.Contains(search));
            }

            // Фильтр по поставщику
            if (supplierId.HasValue)
                query = query.Where(p => p.SupplierId == supplierId.Value);

            // Сортировка 
            if (sortBy?.ToLower() == "price")
                query = ascending == true ? query.OrderBy(p => p.Price) : query.OrderByDescending(p => p.Price);
            else if (sortBy?.ToLower() == "quantity")
                query = ascending == true ? query.OrderBy(p => p.QuantityInStock) : query.OrderByDescending(p => p.QuantityInStock);
            else
                query = query.OrderBy(p => p.Name);

            var products = await query.Select(p => new Product
            {
                Id = p.Id,
                Article = p.Article,
                Name = p.Name,
                Unit = p.Unit,
                Price = p.Price,
                Supplier = p.Supplier != null ? p.Suppliers.Name : null,
                Manufacturer = p.Manufacturer != null ? p.Manufacturers.Name : null,
                Category = p.Category,
                Discount = p.Discount,
                QuantityInStock = p.QuantityInStock,
                Description = p.Description,
                ImagePath = p.ImagePath
            }).ToListAsync();
            return Ok(products);
        }

        [HttpGet("GetId/{id}")]
        public async Task<IActionResult> GetId(int id)
        {
            var product = await _context.Products.FindAsync(id);
            if (product == null) return NotFound();
            return Ok(product);
        }

        [Authorize(Roles = "Администратор")]
        [HttpPost("Post")]
        public async Task<IActionResult> Create([FromBody] ProductCreateDto dto)
        {
            if (!ModelState.IsValid)
                return BadRequest(ModelState);

            var product = new Product
            {
                Article = dto.Article,
                Name = dto.Name,
                Unit = dto.Unit,
                Price = dto.Price,
                SupplierId = dto.SupplierId,
                ManufacturerId = dto.ManufacturerId,
                Category = dto.Category,
                Discount = dto.Discount,
                QuantityInStock = dto.QuantityInStock,
                Description = dto.Description
            };
            _context.Products.Add(product);
            await _context.SaveChangesAsync();
            return Ok(product);
        }

        [Authorize(Roles = "Администратор")]
        [HttpPut("Put/{id}")]
        public async Task<IActionResult> Update(int id, [FromBody] ProductUpdateDto dto)
        {
            if (!ModelState.IsValid)
                return BadRequest(ModelState);

            var product = await _context.Products.FindAsync(id);
            if (product == null) return NotFound();

            product.Article = dto.Article;
            product.Name = dto.Name;
            product.Unit = dto.Unit;
            product.Price = dto.Price;
            product.SupplierId = dto.SupplierId;
            product.ManufacturerId = dto.ManufacturerId;
            product.Category = dto.Category;
            product.Discount = dto.Discount;
            product.QuantityInStock = dto.QuantityInStock;
            product.Description = dto.Description;

            await _context.SaveChangesAsync();
            return NoContent();
        }

        [Authorize(Roles = "Администратор")]
        [HttpDelete("Delete/{id}")]
        public async Task<IActionResult> Delete(int id)
        {
            var usedInOrders = await _context.OrderItems.AnyAsync(oi => oi.ProductId == id);
            if (usedInOrders)
                return BadRequest("Товар присутствует в заказах и не может быть удалён");

            var product = await _context.Products.FindAsync(id);
            if (product == null) return NotFound();

            if (!string.IsNullOrEmpty(product.ImagePath))
            {
                var fullPath = Path.Combine(_env.WebRootPath, product.ImagePath.TrimStart('/'));
                if (System.IO.File.Exists(fullPath))
                    System.IO.File.Delete(fullPath);
            }

            _context.Products.Remove(product);
            await _context.SaveChangesAsync();
            return NoContent();
        }

        [HttpPost("UploadImage/{id}")]
        public async Task<IActionResult> UploadImage(int id, IFormFile file)
        {
            var product = await _context.Products.FindAsync(id);
            if (product == null)
                return NotFound("Продукт не найден");

            if (file == null || file.Length == 0)
                return BadRequest("Файл не выбран");

            var allowedExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif" };
            var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
            if (!allowedExtensions.Contains(ext))
                return BadRequest("Допустимы только изображения (jpg, jpeg, png, gif)");

            if (file.Length > 5 * 1024 * 1024)
                return BadRequest("Размер файла не должен превышать 5 МБ");

            if (!string.IsNullOrEmpty(product.ImagePath))
            {
                var oldPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", product.ImagePath.TrimStart('/'));
                if (System.IO.File.Exists(oldPath))
                    System.IO.File.Delete(oldPath);
            }

            var fileName = $"{Guid.NewGuid()}{ext}";
            var uploadDir = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "images");
            if (!Directory.Exists(uploadDir))
                Directory.CreateDirectory(uploadDir);

            var filePath = Path.Combine(uploadDir, fileName);
            using (var stream = new FileStream(filePath, FileMode.Create))
            {
                await file.CopyToAsync(stream);
            }

            product.ImagePath = $"/images/{fileName}";
            await _context.SaveChangesAsync();

            return Ok(new { imagePath = product.ImagePath });
        }

        // DELETE: 
        [HttpDelete("RemoveImage/{id}")]
        public async Task<IActionResult> RemoveImage(int id)
        {
            var product = await _context.Products.FindAsync(id);
            if (product == null)
                return NotFound();

            if (!string.IsNullOrEmpty(product.ImagePath))
            {
                var filePath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", product.ImagePath.TrimStart('/'));
                if (System.IO.File.Exists(filePath))
                    System.IO.File.Delete(filePath);

                product.ImagePath = null;
                await _context.SaveChangesAsync();
            }
            return Ok();
        }

        [HttpGet("suppliers")]
        public async Task<IActionResult> GetSuppliers()
        {
            var suppliers = await _context.Supliers
                .Select(s => new {s.Id, s.Name})
                .ToListAsync();
            return Ok(suppliers);
        }

        [HttpGet("manufactur")]
        public async Task<IActionResult> GetManufacturers()
        {
            var manufacturers = await _context.Manufacturers
                .Select(m => new { m.Id, m.Name })
                .ToListAsync();
            return Ok(manufacturers);
        }
    }
  1. API пользователей (для админов)
[Route("api/users")]
    [ApiController]
    [Authorize(Roles = "Менеджер,Администратор")]
    public class UsersController : ControllerBase
    {
        private readonly AppDbContext _context;
        public UsersController(AppDbContext context) => _context = context;

        [HttpGet("byrole")]
        public async Task<IActionResult> GetUsersByRole([FromQuery] string roleName)
        {
            var users = await _context.Users
                .Where(u => u.Role.NameRole == roleName)
                .Select(u => new { u.Id, u.FullName })
                .ToListAsync();
            return Ok(users);
        }
    }

HTML страницы управления контролером

  1. Авторизация(login.html)
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Вход</title>
    <style>
        :root {
            --bg-main: #FFFFFF; 
            --bg-secondary: #F5DEB3; 
            --accent: #DEB887; 
            --text-dark: #333333;
            --error: #d9534f;
        }

        * { margin: 0; padding: 0; box-sizing: border-box;}
        body { background-color: var(--bg-main); display: flex; justify-content: center; align-items: center;
            min-height: 100vh;
            padding: 20px;
        }
        .login-container { background-color: var(--bg-secondary); border-radius: 20px; padding: 40px 30px; width: 100%;
            max-width: 400px; text-align: center; }
        h2 { color: var(--accent); font-size: 2.5rem; margin-bottom: 25px; font-weight: normal;
        }
        .input-group { margin-bottom: 20px; text-align: left; }
        label { display: block; margin-bottom: 6px; color: var(--accent); font-size: 1.2rem;
            font-weight: bold; }
        input { width: 100%; padding: 10px 12px; font-size: 1rem; border: 1px solid var(--bg-main); border-radius: 8px;
            background-color: var(--bg-main); transition: 0.2s;
        }
            input:focus {
                outline: none;
                border-color: var(--accent);
                box-shadow: 0 0 0 2px rgba(45, 96, 51, 0.2);
            }
        button { background-color: var(--accent); color: white; border: none; padding: 10px 20px;
            font-size: 1.2rem; border-radius: 30px; cursor: pointer; width: 100%;
            margin-top: 10px;
        }
        .register-link { margin-top: 20px; font-size: 1rem;
        }
            .register-link a {  color: var(--accent); text-decoration: none; font-weight: bold;
            }
                .register-link a:hover {
                    text-decoration: underline;
                }
    </style>
</head>
<body>
    <div class="login-container">
        <h2>Авторизация</h2>
        <div class="input-group">
            <label>Логин</label>
            <input type="text" id="login" placeholder="Введите логин">
        </div>
        <div class="input-group">
            <label>Пароль</label>
            <input type="password" id="password" placeholder="Введите пароль">
        </div>
        <button onclick="login()">Войти</button>
        <div id="error-message" class="message"></div>
        <div class="register-link">
            Нет аккаунта? <a href="register.html">Зарегистрироваться</a>
        </div>
    </div>

    <script>
        const API = "https://localhost:7258/api/account"

        async function login() {
            const login = document.getElementById('login').value;
            const password = document.getElementById('password').value;

            const response = await fetch(`${API}/login`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ login, password })
            });

            if (!response.ok) {
                const error = await response.text();
                alert(error);
                return;
            }

            const data = await response.json();
            sessionStorage.setItem("role", data.role);
            sessionStorage.setItem("login", login);
            window.location.href = "Product.html";
        }
    </script>
</body>
</html>
  1. Регистрация(register.html)
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Регистрация</title>
    <style>
        :root {
            --bg-main: #FFFFFF; 
            --bg-secondary: #F5DEB3; 
            --accent: #DEB887; 
            --text-dark: #333333;
            --error: #d9534f;
        }
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { background-color: var(--bg-main); display: flex; justify-content: center;
            align-items: center; min-height: 100vh; padding: 20px; }
        .register-container { background-color: var(--bg-secondary); border-radius: 20px;
            padding: 35px 30px; width: 100%; max-width: 450px; }
        h2 { color: var(--accent); font-size: 2.5rem; margin-bottom: 25px; font-weight: normal;
        }
        .input-group { margin-bottom: 20px; text-align: left; }
        label { display: block; margin-bottom: 6px; color: var(--accent); font-size: 1.2rem;
            font-weight: bold;
        }
        input { width: 100%; padding: 10px 12px; font-size: 1rem; border: 1px solid var(--bg-main);
            border-radius: 8px; background-color: var(--bg-main); transition: 0.2s;
        }
            input:focus { outline: none; border-color: var(--accent);
                box-shadow: 0 0 0 2px rgba(45, 96, 51, 0.2);
            }
        button { background-color: var(--accent); color: white; border: none; padding: 10px 20px;
            font-size: 1.2rem; border-radius: 30px; cursor: pointer; width: 100%; margin-top: 10px;
        }
        .register-link { margin-top: 20px; font-size: 1rem;
        }
            .register-link a {
                color: var(--accent);
                text-decoration: none;
                font-weight: bold;
            }

                .register-link a:hover {
                    text-decoration: underline;
                }
    </style>
</head>
<body>
    <div class="register-container">
        <h2>Регистрация</h2>

        <div class="input-group">
            <label>ФИО</label>
            <input type="text" id="fullname" placeholder="Введите своё ФИО">
        </div>
        <div class="input-group">
            <label>Логин</label>
            <input type="text" id="login" placeholder="Введите логин">
        </div>
        <div class="input-group">
            <label>Пароль</label>
            <input type="password" id="password" placeholder="Минимум 6 символов">
        </div>
        <div class="input-group">
            <select id="role">
                <option value="1">Администратор</option>
                <option value="2" selected>Менеджер</option>
                <option value="3" selected>Авторизированный клиент</option>
            </select>
        </div>
        <button onclick="register()">Зарегистрироваться</button>
        <div id="message" class="message"></div>
        <div class="login-link">
            Уже есть аккаунт? <a href="login.html">Войти</a>
        </div>
    </div>
    <script>
            const API = "https://localhost:7258/api/account"

            async function register() {
                const response = await fetch(`${API}/register`, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({
                        login: document.getElementById('login').value,
                        password: document.getElementById('password').value,
                        roleId: parseInt(document.getElementById('role').value)
                    })
                });
                const text = await response.text();
                alert(text);
            }
    </script>
</body>
</html>
  1. Продукция(Product.html)
<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <title>Управление товарами – карточки</title>
    <style>
        :root {
            --bg-main: #FFFFFF;
            --bg-secondary: #F5DEB3; 
            --accent: #DEB887; 
            --border: #e0e0e0;
            --shadow: 0 4px 12px rgba(0,0,0,0.08);
            --overlay: rgba(0,0,0,0.5);
            --card-radius: 20px;
        }

        * { box-sizing: border-box; margin: 0; padding: 0;
        }
        body { background-color: #f5f7f5; margin: 0; font-family: 'Segoe UI', 'Arial', sans-serif;
            padding: 2rem;
        }
        header { display: flex; align-items: center; background-color: var(--bg-secondary);
            padding: 15px 30px; border-radius: 60px; margin-bottom: 30px; box-shadow: var(--shadow);
        }

        .logo {
            height: 55px;
            width: auto;
            margin-right: 20px;
        }

        h1 {
            color: var(--accent);
            margin: 0;
            font-weight: 600;
        }

        .toolbar {
            display: flex;
            justify-content: flex-end;
            margin-bottom: 30px;
        }

        .add-button { background-color: var(--accent); color: white; border: none; border-radius: 40px; padding: 12px 28px;
            font-size: 1rem; font-weight: 600; cursor: pointer; transition: 0.2s; box-shadow: 0 2px 5px rgba(0,0,0,0.1);
        }
            .add-button:hover { background-color: #1e4422; transform: scale(1.02);
            }
        .cards-container { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
            gap: 28px; margin-top: 20px;
        }
        .product-card { background: var(--bg-main); border-radius: var(--card-radius); overflow: hidden;
            box-shadow: var(--shadow); transition: transform 0.2s, box-shadow 0.2s; display: flex;
            flex-direction: column;
        }
            .product-card:hover {
                transform: translateY(-5px);
                box-shadow: 0 12px 24px rgba(0,0,0,0.12);
            }
        .card-inner { display: flex; flex-direction: row;padding: 18px; gap: 18px;
        }
        .card-image {  flex-shrink: 0; width: 130px; height: 130px; background: #f0f2f0; border-radius: 16px; display: flex;
            align-items: center; justify-content: center; overflow: hidden; }
            .card-image img { width: 100%; height: 100%; object-fit: cover; border-radius: 16px;
            }
            .card-image .no-image { font-size: 12px; color: #aaa; text-align: center; padding: 8px; }
        .card-details { flex: 1; display: flex; flex-direction: column; gap: 8px; }
        .product-title { font-size: 1.25rem; font-weight: 700; color: #1e3a2f; margin-bottom: 4px;
            line-height: 1.3; }
        .attr-row { display: flex; flex-wrap: wrap; font-size: 0.85rem; border-bottom: 1px dashed #eaeaea;
            padding: 5px 0; }
        .attr-label { font-weight: 600; width: 120px; color: #2D6033; }
        .attr-value { color: #2c3e2b; flex: 1; }
        .card-actions { background: #fafbfa; padding: 12px 18px; display: flex; justify-content: flex-end;
            gap: 12px; border-top: 1px solid #eef2ee; }
        button { background-color: var(--accent); color: white; border: none; border-radius: 30px;
            padding: 6px 16px; font-size: 0.8rem; cursor: pointer; font-weight: 500;
            transition: 0.2s;
        }
            button:hover { background-color: #1e4422; }
        .btn-edit { background-color: #3a7b44;}
        .btn-delete { background-color: #b85c5c;}
            .btn-delete:hover {
                background-color: #9e4242;
            }
        .empty-message { text-align: center; padding: 60px 20px; background: #fefefe; border-radius: 40px;
            color: #7e8c7e; font-size: 1.2rem; }
        .modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%;
            height: 100%; overflow: auto; background-color: var(--overlay); backdrop-filter: blur(2px);
        }
        .modal-content { background-color: var(--bg-main); margin: 5% auto; padding: 24px;
            border-radius: 32px; width: 90%; max-width: 550px; box-shadow: 0 20px 30px rgba(0,0,0,0.2);
        }
        .modal-header { display: flex; justify-content: space-between; align-items: center;
            border-bottom: 2px solid var(--bg-secondary); padding-bottom: 12px; margin-bottom: 20px;
        }
            .modal-header h3 { color: var(--accent); font-size: 1.5rem; }
        .close { font-size: 32px; font-weight: bold; cursor: pointer; color: var(--accent); }
        .form-group { margin-bottom: 18px; display: flex;
            flex-direction: column; }
            .form-group label { font-weight: 600; color: var(--accent); margin-bottom: 6px; }
            .form-group input, .form-group select { padding: 10px; border: 1px solid var(--border);
                border-radius: 20px; font-size: 0.9rem; }

        .current-image { margin-top: 8px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
        }
            .current-image img { max-width: 80px; border-radius: 12px; border: 1px solid #ddd;
            }
        .modal-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 20px; }
        .btn-secondary { background-color: #b0b0b0; }
    </style>
</head>
<body>
    <header>
        <img src="images/icon.png" alt="Логотип" class="logo" id="logoImg" onerror="this.style.display='none'">
        <h1 id="pageTitle">Каталог товаров</h1>
    </header>

    <div class="toolbar">
        <div id="filterPanel" style="display: none; gap: 10px; flex-wrap: wrap; align-items: center;">
            <input type="text" id="searchInput" placeholder="Поиск по названию, категории..." style="width: 250px;">
            <select id="supplierFilter">
                <option value="">Все поставщики</option>
            </select>
            <select id="sortSelect">
                <option value="name_asc">По названию (А→Я)</option>
                <option value="price_asc">По цене (возр.)</option>
                <option value="price_desc">По цене (убыв.)</option>
                <option value="quantity_asc">По кол-ву (возр.)</option>
                <option value="quantity_desc">По кол-ву (убыв.)</option>
            </select>
        </div>
        <div style="display: flex; gap: 10px;">
            <button id="btnOrders" class="add-button">Заказы</button>
            <button id="btnAddNew" class="add-button">Добавить товар</button>
        </div>
    </div>

    
    <div id="cardsContainer" class="cards-container"></div>

    
    <div id="modal" class="modal">
        <div class="modal-content">
            <div class="modal-header">
                <h3 id="modalTitle">Добавить / Редактировать</h3>
                <span class="close" id="closeModalBtn">&times;</span>
            </div>
            <div id="modalFormFields"></div>
            <div class="modal-actions">
                <button id="btnSave">Сохранить</button>
                <button id="btnCancel" class="btn-secondary">Отмена</button>
            </div>
        </div>
    </div>

    <script>
        
        const config = {
            apiBase: "https://localhost:7258/api/products",
            entityName: "товар",
            showImageInCard: true,  // отображение картинок в картоке
            showTitleInCard: true,   // заголовок картоки

            // Какие атрибуты показывать и их подписи (как на фото)
            // isTitle: true - какое поле будет в заголовке картоки | isReference: true - отображение имени другой записи по названию вмсето ID
            cardAttributes: [
                { label: "Артикул", field: "article", fallback: "—" },
                { label: "Наименование товара", field: "name", isTitle: true, fallback: "Без названия" },
                { label: "Единица измерения", field: "unit", fallback: "шт" },
                { label: "Цена", field: "price", fallback: "0" },
                { label: "Поставщик", field: "supplier", fallback: "-", isReference: true },
                { label: "Производитель", field: "manufacturer", fallback: "-", isReference: true },
                { label: "Категория товара", field: "category", fallback: "-" },
                { label: "Скидка", field: "discount", fallback: "-" },
                { label: "Кол. на складе", field: "quantityInStock", fallback: "-" },
                { label: "Описание", field: "description", fallback: "-" }
            ],

            // Поля формы (добавление/редактирование)
            formFields: [
                { key: "id", label: "ID", type: "number", placeholder: "ID авто", readonly: true, hideOnCreate: true },
                { key: "article", label: "Артикул", type: "text", placeholder: "Артикул" },
                { key: "name", label: "Наименование товара", type: "text", placeholder: "Название" },
                { key: "unit", label: "Единица измерения", type: "text", placeholder: "Единица измерения" },
                { key: "price", label: "Цена", type: "number", placeholder: "Цена" },
                {
                    key: "supplierId", label: "Поставщик", type: "select", placeholder: "Выберите поставщика",
                    reference: { apiUrl: "https://localhost:7258/api/products/suppliers", valueField: "id", displayField: "name" }
                },
                {
                    key: "manufacturerId", label: "Производитель", type: "select", placeholder: "Выберите производителя",
                    reference: { apiUrl: "https://localhost:7258/api/products/manufactur", valueField: "id", displayField: "name" }
                },
                { key: "category", label: "Категория товара", type: "text", placeholder: "Категория товара" },
                { key: "discount", label: "Скидка", type: "number", placeholder: "Скидка" },
                { key: "quantityInStock", label: "Кол. на складе", type: "text", placeholder: "Кол. на складе" },
                { key: "description", label: "Описание", type: "text", placeholder: "Описание" }
            ],

            // Загрузка фото (отдельным эндпоинтом)
            fileField: {
                key: "file",
                label: "Изображение (.png, .jpg)",
                accept: ".png,.jpg,.jpeg",
                urlField: "imagePath",
                separateUpload: true,
                uploadEndpoint: "/UploadImage/{id}"
            },

            endpoints: {
                get: "/Get",
                post: "/Post",
                put: id => `/Put/${id}`,
                delete: id => `/Delete/${id}`
            },

            idField: "id",
            selectors: {
                pageTitle: "#pageTitle",
                modal: "#modal",
                modalTitle: "#modalTitle",
                modalFormFields: "#modalFormFields",
                btnSave: "#btnSave",
                btnCancel: "#btnCancel",
                closeModalBtn: "#closeModalBtn",
                btnAddNew: "#btnAddNew"
            }
        };

        // ============ ГЛОБАЛЬНЫЕ ПЕРЕМЕННЫЕ ============
        let editingId = null;
        let currentImageUrl = null;
        let referenceDataCache = {};
        const userRole = sessionStorage.getItem("role") || localStorage.getItem("role");
        const isAdminOrManager = userRole === "Администратор" || userRole === "Менеджер";

        // Загрузка справочных данных (например, productTypeId -> название производителя)
        async function loadAllReferenceMaps() {
            const fieldsWithRef = config.formFields.filter(f => f.reference);
            for (const field of fieldsWithRef) {
                const { apiUrl, valueField, displayField } = field.reference;
                try {
                    const res = await fetch(apiUrl);
                    if (!res.ok) throw new Error(`HTTP ${res.status}`);
                    const data = await res.json();
                    const map = new Map();
                    data.forEach(item => map.set(item[valueField], item[displayField]));
                    referenceDataCache[field.key] = map;

                    // Заполняем select в форме
                    const selectEl = document.getElementById(field.key);
                    if (selectEl) {
                        selectEl.innerHTML = `<option value="">${field.placeholder || "-- Выберите --"}</option>`;
                        data.forEach(item => {
                            const option = document.createElement("option");
                            option.value = item[valueField];
                            option.textContent = item[displayField];
                            selectEl.appendChild(option);
                        });
                    }
                } catch (err) {
                    console.error(`Ошибка загрузки справочника ${field.key}:`, err);
                    alert(`Не удалось загрузить справочник "${field.label}". Проверьте соединение или API.`);

                    referenceDataCache[field.key] = new Map();
                }
            }
        }

        // Получить отображаемое значение для поля, если требуется справочник
        function getDisplayValue(item, fieldConfig) {
            let rawValue = item[fieldConfig.field];
            if (fieldConfig.isReference && rawValue != null) {
                const cacheMap = referenceDataCache[fieldConfig.field];
                if (cacheMap && cacheMap.has(rawValue)) {
                    return cacheMap.get(rawValue);
                }
            }
            if (rawValue === undefined || rawValue === null) return fieldConfig.fallback || "—";
            return rawValue;
        }

        // Отрисовка карточек (вместо таблицы)
        function renderCards(items) {
            const container = document.getElementById("cardsContainer");
            if (!container) return;
            container.innerHTML = "";
            if (!items || items.length === 0) {
                container.innerHTML = '<div class="empty-message">Нет записей. Нажмите "Добавить"</div>';
                return;
            }
            items.forEach(item => {
                const card = document.createElement("div");
                card.className = "product-card";
                const innerDiv = document.createElement("div");
                innerDiv.className = "card-inner";

                if (config.showImageInCard) {
                    const imgDiv = document.createElement("div");
                    imgDiv.className = "card-image";
                    const imgUrl = item.imagePath && item.imagePath.trim() !== "" ? item.imagePath : "/images/picture.png";
                    if (imgUrl) {
                        const img = document.createElement("img");
                        img.src = imgUrl;
                        img.alt = item.nameProduct || "Фото";
                        img.onerror = () => { img.style.display = 'none'; img.parentElement.innerHTML = '<div class="no-image">нет фото</div>'; };
                        imgDiv.appendChild(img);
                    } else {
                        imgDiv.innerHTML = '<div class="no-image">нет фото</div>';
                    }
                    innerDiv.appendChild(imgDiv);
                }

                const detailsDiv = document.createElement("div");
                detailsDiv.className = "card-details";

                if (config.showTitleInCard) {
                    const titleField = config.cardAttributes.find(attr => attr.isTitle === true);
                    let titleText = "Запись";
                    if (titleField) {
                        titleText = getDisplayValue(item, titleField);
                    } else {
                        titleText = item.nameProduct || "Без названия";
                    }
                    const titleElem = document.createElement("div");
                    titleElem.className = "product-title";
                    titleElem.textContent = titleText;
                    detailsDiv.appendChild(titleElem);
                }

                config.cardAttributes.forEach(attr => {
                    if (attr.isTitle) return;
                    const row = document.createElement("div");
                    row.className = "attr-row";
                    const labelSpan = document.createElement("span");
                    labelSpan.className = "attr-label";
                    labelSpan.textContent = attr.label + ":";
                    const valueSpan = document.createElement("span");
                    valueSpan.className = "attr-value";
                    valueSpan.textContent = getDisplayValue(item, attr);
                    row.appendChild(labelSpan);
                    row.appendChild(valueSpan);
                    detailsDiv.appendChild(row);
                });

                innerDiv.appendChild(detailsDiv);
                card.appendChild(innerDiv);

                const actionsDiv = document.createElement("div");
                actionsDiv.className = "card-actions";
                const editBtn = document.createElement("button");
                editBtn.textContent = "Редактировать";
                editBtn.classList.add("btn-edit");
                editBtn.onclick = (e) => { e.stopPropagation(); openModalForEdit(item); };
                const delBtn = document.createElement("button");
                delBtn.textContent = "Удалить";
                delBtn.classList.add("btn-delete");
                delBtn.onclick = (e) => { e.stopPropagation(); deleteItem(item[config.idField]); };
                actionsDiv.appendChild(editBtn);
                actionsDiv.appendChild(delBtn);
                card.appendChild(actionsDiv);
                container.appendChild(card);
            });
        }

        // ---------- ЛОГИКА ----------
        function buildForm() {
            const container = document.querySelector(config.selectors.modalFormFields);
            container.innerHTML = "";
            config.formFields.forEach(field => {
                const div = document.createElement("div");
                div.className = "form-group";
                const label = document.createElement("label");
                label.textContent = field.label;
                div.appendChild(label);
                if (field.type === "select") {
                    const select = document.createElement("select");
                    select.id = field.key;
                    select.innerHTML = `<option value="">${field.placeholder || "-- Выберите --"}</option>`;
                    if (field.readonly) select.disabled = true;
                    div.appendChild(select);
                } else {
                    const input = document.createElement("input");
                    input.type = field.type || "text";
                    input.id = field.key;
                    input.placeholder = field.placeholder || "";
                    if (field.readonly) input.readOnly = true;
                    div.appendChild(input);
                }
                container.appendChild(div);
            });
            if (config.fileField) {
                const ff = config.fileField;
                const div = document.createElement("div");
                div.className = "form-group";
                const label = document.createElement("label");
                label.textContent = ff.label;
                div.appendChild(label);
                const input = document.createElement("input");
                input.type = "file";
                input.id = "fileInput";
                input.accept = ff.accept || ".png,.jpg,.jpeg";
                div.appendChild(input);
                const currentDiv = document.createElement("div");
                currentDiv.className = "current-image";
                currentDiv.id = "currentImageBlock";
                currentDiv.style.display = "none";
                div.appendChild(currentDiv);
                container.appendChild(div);
            }
        }

        async function loadData() {
            try {
                const res = await fetch(`${config.apiBase}${config.endpoints.get}`);
                if (!res.ok) throw new Error(`Ошибка загрузки: ${res.status}`);
                const items = await res.json();
                renderCards(items);
            } catch (err) {
                alert("Ошибка загрузки данных: " + err.message);
                document.getElementById("cardsContainer").innerHTML = '<div class="empty-message">Не удалось загрузить</div>';
            }
        }

        function openModalForCreate() {
            editingId = null;
            currentImageUrl = null;
            document.querySelector(config.selectors.modalTitle).textContent = "Добавить";
            config.formFields.forEach(field => {
                const el = document.getElementById(field.key);
                if (!el) return;
                const group = el.closest('.form-group');
                if (field.hideOnCreate) group.style.display = 'none';
                else group.style.display = 'block';
                if (field.type === "select") el.value = "";
                else el.value = "";
            });
            const fileInput = document.getElementById("fileInput");
            if (fileInput) fileInput.value = "";
            const curBlock = document.getElementById("currentImageBlock");
            if (curBlock) { curBlock.style.display = "none"; curBlock.innerHTML = ""; }
            document.querySelector(config.selectors.modal).style.display = "block";
        }

        function openModalForEdit(item) {
            editingId = item[config.idField];
            document.querySelector(config.selectors.modalTitle).textContent = "Редактировать";
            config.formFields.forEach(field => {
                const el = document.getElementById(field.key);
                if (!el) return;
                el.closest('.form-group').style.display = 'block';
                el.value = (item[field.key] !== undefined && item[field.key] !== null) ? item[field.key] : "";
            });
            if (config.fileField) {
                const fileInput = document.getElementById("fileInput");
                if (fileInput) fileInput.value = "";
                const curBlock = document.getElementById("currentImageBlock");
                const imgUrl = item[config.fileField.urlField];
                currentImageUrl = imgUrl;
                if (curBlock) {
                    curBlock.innerHTML = "";
                    if (imgUrl) {
                        curBlock.style.display = "flex";
                        const img = document.createElement("img");
                        img.src = imgUrl;
                        curBlock.appendChild(img);
                        const txt = document.createElement("span");
                        txt.textContent = "текущее фото (замените новым)";
                        curBlock.appendChild(txt);
                    } else curBlock.style.display = "none";
                }
            }
            document.querySelector(config.selectors.modal).style.display = "block";
        }

        function closeModal() {
            document.querySelector(config.selectors.modal).style.display = "none";
        }

        async function saveItem() {
            const hasFile = config.fileField && document.getElementById("fileInput")?.files?.length > 0;
            const jsonData = {};
            config.formFields.forEach(field => {
                const el = document.getElementById(field.key);
                if (!el) return;
                if (field.hideOnCreate && !editingId) return;
                let val = el.value;
                if (field.type === "number" && val !== "") val = parseFloat(val);
                jsonData[field.key] = (val === "") ? null : val;
            });

            const url = editingId ? `${config.apiBase}${config.endpoints.put(editingId)}` : `${config.apiBase}${config.endpoints.post}`;
            const method = editingId ? "PUT" : "POST";

            try {
                const mainRes = await fetch(url, {
                    method: method,
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify(jsonData)
                });
                if (!mainRes.ok) throw new Error(`Ошибка сохранения: ${mainRes.status}`);
                let recordId = editingId;
                if (!editingId) {
                    const created = await mainRes.json();
                    recordId = created[config.idField];
                    if (!recordId) throw new Error("Не получен ID");
                }
                if (hasFile && config.fileField.separateUpload) {
                    const file = document.getElementById("fileInput").files[0];
                    const formData = new FormData();
                    formData.append(config.fileField.key, file);
                    const uploadUrl = `${config.apiBase}${config.fileField.uploadEndpoint.replace("{id}", recordId)}`;
                    const uploadRes = await fetch(uploadUrl, { method: "POST", body: formData });
                    if (!uploadRes.ok) throw new Error("Ошибка загрузки фото");
                }
                closeModal();
                await loadData();
            } catch (err) { alert(err.message); }
        }

        async function deleteItem(id) {
            if (!confirm(`Удалить запись с ID ${id}?`)) return;
            try {
                const res = await fetch(`${config.apiBase}${config.endpoints.delete(id)}`, { method: "DELETE" });
                if (!res.ok) throw new Error(`Ошибка удаления: ${res.status}`);
                await loadData();
            } catch (err) { alert("Ошибка: " + err.message); }
        }

        // ---- ЗАГРУЗКА СПИСКА ПОСТАВЩИКОВ ДЛЯ ФИЛЬТРА ----
        async function loadSuppliersForFilter() {
            try {
                const res = await fetch("https://localhost:7258/api/products/suppliers");
                if (!res.ok) throw new Error();
                const suppliers = await res.json();
                const select = document.getElementById("supplierFilter");
                select.innerHTML = '<option value="">Все поставщики</option>';
                suppliers.forEach(s => {
                    const option = document.createElement("option");
                    option.value = s.id;
                    option.textContent = s.name;
                    select.appendChild(option);
                });
            } catch (err) {
                console.warn("Не удалось загрузить поставщиков для фильтра");
            }
        }

        async function loadDataFiltered() {
            const search = document.getElementById("searchInput")?.value || "";
            const supplierId = document.getElementById("supplierFilter")?.value || "";
            const sortValue = document.getElementById("sortSelect")?.value || "name_asc";

            let sortBy = "name";
            let ascending = true;
            if (sortValue === "price_asc") { sortBy = "price"; ascending = true; }
            else if (sortValue === "price_desc") { sortBy = "price"; ascending = false; }
            else if (sortValue === "quantity_asc") { sortBy = "quantity"; ascending = true; }
            else if (sortValue === "quantity_desc") { sortBy = "quantity"; ascending = false; }
            else { sortBy = "name"; ascending = true; }

            let url = `${config.apiBase}/GetAll?search=${encodeURIComponent(search)}&sortBy=${sortBy}&ascending=${ascending}`;
            if (supplierId) url += `&supplierId=${supplierId}`;

            try {
                const res = await fetch(url, { credentials: 'include' });
                if (!res.ok) throw new Error(`HTTP ${res.status}`);
                const items = await res.json();
                renderCards(items);
            } catch (err) {
                console.error(err);
                document.getElementById("cardsContainer").innerHTML = '<div class="empty-message">Ошибка загрузки</div>';
            }
        }

        // ---- ПУБЛИЧНЫЙ МЕТОД ДЛЯ ГОСТЕЙ/КЛИЕНТОВ (БЕЗ ФИЛЬТРОВ) ----
        async function loadDataPublic() {
            try {
                const res = await fetch(`${config.apiBase}${config.endpoints.get}`);
                if (!res.ok) throw new Error(`Ошибка загрузки: ${res.status}`);
                const items = await res.json();
                renderCards(items);
            } catch (err) {
                alert("Ошибка загрузки данных: " + err.message);
                document.getElementById("cardsContainer").innerHTML = '<div class="empty-message">Не удалось загрузить</div>';
            }
        }

        async function init() {
            document.querySelector(config.selectors.pageTitle).textContent = "Каталог товаров";
            buildForm();
            await loadAllReferenceMaps(); // справочники для модалки

            // Показываем/скрываем панель фильтров в зависимости от роли
            const filterPanel = document.getElementById("filterPanel");
            const btnOrders = document.getElementById("btnOrders");
            const btnAddNew = document.getElementById("btnAddNew");
            if (isAdminOrManager) {
                filterPanel.style.display = "flex";
                btnOrders.style.display = "inline-block";
                await loadSuppliersForFilter();
                // Обработчики событий для фильтров (в реальном времени)
                document.getElementById("searchInput").addEventListener("input", () => loadDataFiltered());
                document.getElementById("supplierFilter").addEventListener("change", () => loadDataFiltered());
                document.getElementById("sortSelect").addEventListener("change", () => loadDataFiltered());
                await loadDataFiltered();
            } else {
                filterPanel.style.display = "none";
                btnOrders.style.display = "none";
                btnAddNew.style.display = "none";
                await loadDataPublic();
            }

            // Кнопка "Заказы"
            document.getElementById("btnOrders").addEventListener("click", () => {
                window.location.href = "orders.html";
            });

            // Остальные обработчики (добавление, редактирование, удаление) остаются без изменений
            document.querySelector(config.selectors.btnAddNew).addEventListener("click", openModalForCreate);
            document.querySelector(config.selectors.btnSave).addEventListener("click", saveItem);
            document.querySelector(config.selectors.btnCancel).addEventListener("click", closeModal);
            document.querySelector(config.selectors.closeModalBtn).addEventListener("click", closeModal);
            window.addEventListener("click", (e) => { if (e.target === document.querySelector(config.selectors.modal)) closeModal(); });
        }

        window.addEventListener("DOMContentLoaded", init);
    </script>
</body>
</html>
  1. Заказы (orders.html)
<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <title>Управление заказами</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            background-color: #f5f7f5;
            font-family: 'Segoe UI', 'Arial', sans-serif;
            padding: 2rem;
        }

        header { display: flex; align-items: center; justify-content: space-between; background-color: #F5DEB3; padding: 15px 30px; border-radius: 60px; margin-bottom: 30px;}
        .logo {
            height: 55px;
            width: auto;
            margin-right: 20px;
        }
        h1 { color: rgba(0,0,0,0.12); margin: 0;}

        .toolbar {
            display: flex;
            justify-content: flex-end;
            margin-bottom: 30px;
        }
        .add-button { background-color: #DEB887; color: white; border: none; border-radius: 40px; padding: 12px 28px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: 0.2s; }
            .add-button:hover { background-color: #c2a06b; }
        .cards-container { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 28px; margin-top: 20px; }
        .order-card { background: white; border-radius: 20px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.08); transition: transform 0.2s, box-shadow 0.2s; display: flex; flex-direction: column; }
            .order-card:hover { transform: translateY(-5px); box-shadow: 0 12px 24px rgba(0,0,0,0.12); }
        .card-inner { padding: 18px; display: flex; flex-direction: column; gap: 8px; }
        .order-title { font-size: 1.3rem; font-weight: 700; color: #1e3a2f; margin-bottom: 10px; border-bottom: 2px solid #F5DEB3; padding-bottom: 5px; }
        .attr-row { display: flex; flex-wrap: wrap; font-size: 0.9rem; padding: 5px 0; border-bottom: 1px dashed #eaeaea; }
        .attr-label { font-weight: 600; width: 140px; color: #2D6033; }
        .attr-value { color: #2c3e2b; flex: 1; }
        .card-actions { background: #fafbfa; padding: 12px 18px; display: flex; justify-content: flex-end; gap: 12px; border-top: 1px solid #eef2ee; }
        button { background-color: #DEB887; color: white;  border: none; border-radius: 30px; padding: 6px 16px; font-size: 0.8rem; cursor: pointer; font-weight: 500; }
            button:hover { background-color: #c2a06b; }
        .btn-edit { background-color: #3a7b44; }
        .btn-delete { background-color: #b85c5c; }
        .empty-message { text-align: center; padding: 60px 20px; background: white; border-radius: 40px; color: #7e8c7e; font-size: 1.2rem; }
        .modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.5); backdrop-filter: blur(2px); }
        .modal-content { background-color: white; margin: 5% auto; padding: 24px; border-radius: 32px; width: 90%; max-width: 550px; max-height: 85vh;  overflow-y: auto;  box-shadow: 0 20px 30px rgba(0,0,0,0.2); }
        .modal-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #F5DEB3; padding-bottom: 12px; margin-bottom: 20px; }
            .modal-header h3 { color: #2D6033; }
        .close { font-size: 32px; font-weight: bold; cursor: pointer; color: #2D6033; }
        .form-group { margin-bottom: 18px; display: flex; flex-direction: column; }
            .form-group label { font-weight: 600; color: #2D6033; margin-bottom: 6px; }
            .form-group input, .form-group select { padding: 10px; border: 1px solid #ccc; border-radius: 20px; font-size: 0.9rem; }
        .item-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; flex-wrap: wrap;  }
            .item-row select { flex: 2; min-width: 120px; }
            .item-row input { width: 80px; }
        .remove-item {  background: #b85c5c; padding: 4px 10px; white-space: nowrap; }
        .modal-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 20px; }
        .btn-secondary { background-color: #b0b0b0; }
    </style>
</head>
<body>
    <header>
        <img src="images/icon.png" alt="Логотип" class="logo" onerror="this.style.display='none'">
        <h1>Управление заказами</h1>
        <button onclick="window.location.href='Product.html'" style="background: #DEB887;">К каталогу товаров</button>
    </header>
    <div class="toolbar">
        <button class="add-button" id="btnAddNew">Добавить заказ</button>
    </div>
    <div id="cardsContainer" class="cards-container"></div>

    
    <div id="addModal" class="modal">
        <div class="modal-content">
            <div class="modal-header">
                <h3>Добавление заказа</h3>
                <span class="close" id="closeAddModalBtn">&times;</span>
            </div>
            <div id="addModalFormFields" class="modal-form-fields"></div>
            <div class="modal-actions">
                <button id="addBtnSave">Сохранить</button>
                <button id="addBtnCancel" class="btn-secondary">Отмена</button>
            </div>
        </div>
    </div>

    
    <div id="editModal" class="modal">
        <div class="modal-content">
            <div class="modal-header">
                <h3>Редактирование заказа</h3>
                <span class="close" id="closeEditModalBtn">&times;</span>
            </div>
            <div id="editModalFormFields" class="modal-form-fields"></div>
            <div class="modal-actions">
                <button id="editBtnSave">Сохранить</button>
                <button id="editBtnCancel" class="btn-secondary">Отмена</button>
            </div>
        </div>
    </div>

    <script>
        const config = {
            apiBase: "https://localhost:7258/api/orders",
            apiProducts: "https://localhost:7258/api/products",
            showImageInCard: false,
            showTitleInCard: true,
            cardAttributes: [
                { label: "Номер заказа", field: "orderNumber", isTitle: true, fallback: "—" },
                { label: "Дата заказа", field: "orderDate", fallback: "—" },
                { label: "Дата доставки", field: "deliveryDate", fallback: "—" },
                { label: "Статус", field: "status", fallback: "—" },
                { label: "Клиент", field: "userId", isReference: true, fallback: "—" },
                { label: "Пункт выдачи", field: "pickupPointId", isReference: true, fallback: "—" },
                { label: "Код получения", field: "pickupCode", fallback: "—" }
            ],
            formFields: [
                { key: "id", label: "ID", type: "number", readonly: true, hideOnCreate: true },
                { key: "orderNumber", label: "Номер заказа", type: "text" },
                { key: "deliveryDate", label: "Дата доставки", type: "date" },
                { key: "status", label: "Статус", type: "text" },
                {
                    key: "userId", label: "Клиент", type: "select",
                    reference: { apiUrl: "https://localhost:7258/api/users/byrole?roleName=Авторизированный клиент", valueField: "id", displayField: "fullName" }
                },
                {
                    key: "pickupPointId", label: "Пункт выдачи", type: "select",
                    reference: { apiUrl: "https://localhost:7258/api/pickuppoints", valueField: "id", displayField: "address" }
                },
                { key: "pickupCode", label: "Код получения", type: "text" },
                { key: "items", label: "Товары", type: "items" }
            ],
            endpoints: {
                get: "/Get",
                post: "/Post",
                put: id => `/Put/${id}`,
                delete: id => `/Delete/${id}`
            },
            idField: "id"
        };

        let editingId = null;
        let referenceDataCache = {};
        let productsList = [];

        // Загрузка справочника и заполнение select в обеих модалках
        async function loadReference(field) {
            const { apiUrl, valueField, displayField } = field.reference;
            try {
                const res = await fetch(apiUrl);
                if (!res.ok) throw new Error(`HTTP ${res.status}`);
                const data = await res.json();
                const map = new Map();
                data.forEach(item => map.set(item[valueField], item[displayField]));
                referenceDataCache[field.key] = map;

                // Заполняем select в обоих окнах
                for (const modalId of ["addModal", "editModal"]) {
                    const selectEl = document.querySelector(`#${modalId} .modal-form-fields #${field.key}`);
                    if (selectEl) {
                        selectEl.innerHTML = `<option value="">${field.placeholder || "-- Выберите --"}</option>`;
                        data.forEach(item => {
                            const option = document.createElement("option");
                            option.value = item[valueField];
                            option.textContent = item[displayField];
                            selectEl.appendChild(option);
                        });
                    }
                }
            } catch (err) {
                console.error(`Ошибка справочника ${field.key}:`, err);
                referenceDataCache[field.key] = new Map();
            }
        }

        async function loadProductsList() {
            try {
                const res = await fetch(`${config.apiProducts}/Get`);
                if (res.ok) productsList = await res.json();
                else console.warn("Не удалось загрузить товары");
            } catch (e) { console.warn(e); }
        }

        async function loadAllReferenceMaps() {
            for (const field of config.formFields.filter(f => f.reference)) await loadReference(field);
            await loadProductsList();
        }

        function getDisplayValue(item, fieldConfig) {
            let raw = item[fieldConfig.field];
            if (fieldConfig.isReference && raw != null) {
                const map = referenceDataCache[fieldConfig.field];
                if (map && map.has(raw)) return map.get(raw);
            }
            if (raw === undefined || raw === null) return fieldConfig.fallback || "—";
            if (fieldConfig.field === "orderDate" || fieldConfig.field === "deliveryDate")
                return new Date(raw).toLocaleDateString();
            if (fieldConfig.field === "itemsCount") {
                const cnt = item.items?.length || item.orderItems?.length || 0;
                return cnt;
            }
            return raw;
        }

        function renderCards(items) {
            const container = document.getElementById("cardsContainer");
            if (!container) return;
            container.innerHTML = "";
            if (!items?.length) {
                container.innerHTML = '<div class="empty-message">Нет заказов. Нажмите "Добавить"</div>';
                return;
            }
            items.forEach(item => {
                const card = document.createElement("div");
                card.className = "order-card";
                const innerDiv = document.createElement("div");
                innerDiv.className = "card-inner";
                if (config.showTitleInCard) {
                    const titleField = config.cardAttributes.find(a => a.isTitle === true);
                    let titleText = "Заказ";
                    if (titleField) titleText = getDisplayValue(item, titleField);
                    const titleElem = document.createElement("div");
                    titleElem.className = "order-title";
                    titleElem.textContent = titleText;
                    innerDiv.appendChild(titleElem);
                }
                config.cardAttributes.forEach(attr => {
                    if (attr.isTitle) return;
                    const row = document.createElement("div");
                    row.className = "attr-row";
                    row.innerHTML = `<span class="attr-label">${attr.label}:</span><span class="attr-value">${getDisplayValue(item, attr)}</span>`;
                    innerDiv.appendChild(row);
                });
                card.appendChild(innerDiv);
                const actionsDiv = document.createElement("div");
                actionsDiv.className = "card-actions";
                const editBtn = document.createElement("button");
                editBtn.textContent = "Редактировать";
                editBtn.classList.add("btn-edit");
                editBtn.onclick = () => openModalForEdit(item);
                const delBtn = document.createElement("button");
                delBtn.textContent = "Удалить";
                delBtn.classList.add("btn-delete");
                delBtn.onclick = () => deleteItem(item.id);
                actionsDiv.appendChild(editBtn);
                actionsDiv.appendChild(delBtn);
                card.appendChild(actionsDiv);
                container.appendChild(card);
            });
        }

        // Работа с таблицей товаров внутри модалки
        function addItemRow(container, productId = null, quantity = 1) {
            const row = document.createElement("div");
            row.className = "item-row";
            const select = document.createElement("select");
            select.innerHTML = '<option value="">-- Выберите товар --</option>';
            productsList.forEach(p => {
                const opt = document.createElement("option");
                opt.value = p.id;
                opt.textContent = `${p.name} (${p.price} руб.)`;
                if (productId && p.id == productId) opt.selected = true;
                select.appendChild(opt);
            });
            const qtyInput = document.createElement("input");
            qtyInput.type = "number";
            qtyInput.value = quantity;
            qtyInput.min = 1;
            qtyInput.step = 1;
            qtyInput.style.width = "80px";
            const removeBtn = document.createElement("button");
            removeBtn.textContent = "✖";
            removeBtn.className = "remove-item";
            removeBtn.onclick = () => row.remove();
            row.appendChild(select);
            row.appendChild(qtyInput);
            row.appendChild(removeBtn);
            container.appendChild(row);
        }

        function getItemsFromForm(modalId) {
            const container = document.querySelector(`#${modalId} .modal-form-fields #items`);
            const items = [];
            if (container) {
                for (const row of container.querySelectorAll(".item-row")) {
                    const select = row.querySelector("select");
                    const qty = row.querySelector("input[type='number']");
                    const productId = parseInt(select.value);
                    const quantity = parseInt(qty.value);
                    if (productId && quantity > 0) items.push({ productId, quantity });
                }
            }
            return items;
        }

        // Построение формы для конкретной модалки
        function buildForm(modalId) {
            const container = document.querySelector(`#${modalId} .modal-form-fields`);
            if (!container) return;
            container.innerHTML = "";
            config.formFields.forEach(field => {
                const div = document.createElement("div");
                div.className = "form-group";
                const label = document.createElement("label");
                label.textContent = field.label;
                div.appendChild(label);
                if (field.type === "select") {
                    const select = document.createElement("select");
                    select.id = field.key;   // id не уникальны, но они лежат в разных контейнерах, поэтому конфликта нет
                    select.innerHTML = `<option value="">${field.placeholder || "-- Выберите --"}</option>`;
                    if (field.readonly) select.disabled = true;
                    div.appendChild(select);
                } else if (field.type === "date") {
                    const input = document.createElement("input");
                    input.type = "date";
                    input.id = field.key;
                    div.appendChild(input);
                } else if (field.type === "items") {
                    const itemsContainer = document.createElement("div");
                    itemsContainer.id = field.key;
                    itemsContainer.className = "items-list";
                    const addBtn = document.createElement("button");
                    addBtn.type = "button";
                    addBtn.textContent = "Добавить товар";
                    addBtn.style.marginTop = "8px";
                    addBtn.onclick = () => addItemRow(itemsContainer);
                    div.appendChild(itemsContainer);
                    div.appendChild(addBtn);
                } else {
                    const input = document.createElement("input");
                    input.type = field.type || "text";
                    input.id = field.key;
                    input.placeholder = field.placeholder || "";
                    if (field.readonly) input.readOnly = true;
                    div.appendChild(input);
                }
                container.appendChild(div);
            });
        }

        async function loadData() {
            try {
                const res = await fetch(`${config.apiBase}${config.endpoints.get}`, { credentials: 'include' });
                if (!res.ok) throw new Error(`Ошибка загрузки: ${res.status}`);
                const items = await res.json();
                renderCards(items);
            } catch (err) {
                console.error(err);
                document.getElementById("cardsContainer").innerHTML = '<div class="empty-message">Не удалось загрузить заказы</div>';
            }
        }

        // Открытие окна добавления
        function openModalForCreate() {
            editingId = null;
            const modal = document.getElementById("addModal");
            // Очищаем поля внутри addModal
            const container = modal.querySelector(".modal-form-fields");
            if (container) {
                // Очищаем все input, select и items
                container.querySelectorAll("input, select").forEach(el => {
                    if (el.type !== "file") el.value = "";
                });
                const itemsContainer = container.querySelector("#items");
                if (itemsContainer) itemsContainer.innerHTML = "";
            }
            modal.style.display = "block";
        }

        // Открытие окна редактирования с загрузкой данных
        async function openModalForEdit(item) {
            const id = item.id;
            try {
                const res = await fetch(`${config.apiBase}/GetId/${id}`, { credentials: 'include' });
                if (!res.ok) throw new Error('Ошибка загрузки данных заказа');
                const fullOrder = await res.json();
                editingId = fullOrder.id;
                const modal = document.getElementById("editModal");
                const container = modal.querySelector(".modal-form-fields");
                if (!container) return;
                // Заполняем поля
                for (const field of config.formFields) {
                    const el = container.querySelector(`#${field.key}`);
                    if (!el) continue;
                    if (field.type === 'items') {
                        el.innerHTML = '';
                        const items = fullOrder.items || [];
                        if (items.length === 0) {
                            addItemRow(el);
                        } else {
                            items.forEach(it => addItemRow(el, it.productId, it.quantity));
                        }
                    } else if (field.type === 'date') {
                        let val = fullOrder[field.key];
                        if (val) val = val.split('T')[0];
                        el.value = val || '';
                    } else {
                        el.value = fullOrder[field.key] !== undefined ? fullOrder[field.key] : '';
                    }
                }
                modal.style.display = "block";
            } catch (err) {
                alert(err.message);
            }
        }

        function closeModal(modalId) {
            document.getElementById(modalId).style.display = "none";
        }

        // Сохранение (isEditMode = true для editModal)
        async function saveItem(isEditMode = false) {
            const modalId = isEditMode ? "editModal" : "addModal";
            const modal = document.getElementById(modalId);
            const container = modal.querySelector(".modal-form-fields");
            const data = {};
            for (const field of config.formFields) {
                if (field.hideOnCreate && !isEditMode) continue;
                const el = container.querySelector(`#${field.key}`);
                if (!el) continue;
                if (field.type === "items") {
                    data[field.key] = getItemsFromForm(modalId);
                    continue;
                }
                let val = el.value;
                if (field.type === "number" && val !== "") val = parseFloat(val);
                if (field.type === "date" && val === "") val = null;
                data[field.key] = (val === "") ? null : val;
            }
            const url = isEditMode ? `${config.apiBase}${config.endpoints.put(editingId)}` : `${config.apiBase}${config.endpoints.post}`;
            const method = isEditMode ? "PUT" : "POST";
            try {
                const res = await fetch(url, {
                    method,
                    headers: { "Content-Type": "application/json" },
                    credentials: 'include',
                    body: JSON.stringify(data)
                });
                if (!res.ok) throw new Error(await res.text() || `Ошибка ${res.status}`);
                closeModal(modalId);
                await loadData();
                alert(isEditMode ? "Заказ обновлён" : "Заказ добавлен");
            } catch (err) {
                alert("Ошибка: " + err.message);
            }
        }

        async function deleteItem(id) {
            if (!confirm(`Удалить заказ с ID ${id}?`)) return;
            try {
                const res = await fetch(`${config.apiBase}${config.endpoints.delete(id)}`, { method: "DELETE", credentials: 'include' });
                if (!res.ok) throw new Error(`Ошибка удаления: ${res.status}`);
                await loadData();
                alert("Заказ удалён");
            } catch (err) {
                alert("Ошибка: " + err.message);
            }
        }

        async function init() {
            // Построить формы для обоих окон
            buildForm("addModal");
            buildForm("editModal");
            await loadAllReferenceMaps();
            await loadData();

            // Кнопки добавления
            document.getElementById("btnAddNew").addEventListener("click", openModalForCreate);
            document.getElementById("addBtnSave").addEventListener("click", () => saveItem(false));
            document.getElementById("addBtnCancel").addEventListener("click", () => closeModal("addModal"));
            document.getElementById("closeAddModalBtn").addEventListener("click", () => closeModal("addModal"));

            // Кнопки редактирования
            document.getElementById("editBtnSave").addEventListener("click", () => saveItem(true));
            document.getElementById("editBtnCancel").addEventListener("click", () => closeModal("editModal"));
            document.getElementById("closeEditModalBtn").addEventListener("click", () => closeModal("editModal"));

            // Закрытие по клику вне окна
            window.addEventListener("click", (e) => {
                if (e.target === document.getElementById("addModal")) closeModal("addModal");
                if (e.target === document.getElementById("editModal")) closeModal("editModal");
            });
        }

        window.addEventListener("DOMContentLoaded", init);
    </script>
</body>
</html>

Использование системы авторизации

// 1. Присвоить атрибут для контроллеров и необходимых методов
[Authorize(Roles ="Менеджер, Специалист")]

// 2. Функции для проверки прав вставлять в <script> после переменной с адресом
// isAdmin() нужен для скрытия элементов и запрета на использования функций, fetchWithAuth использовать за место метода fetch

        const userRole = sessionStorage.getItem("role");
        function isAdmin() { return userRole === "Менеджер"; }

        async function fetchWithAuth(url, options = {}) {
            const res = await fetch(url, { ...options, credentials: 'include' });
            if (res.status === 401 || res.status === 403) {
                alert("Сессия истекла. Войдите заново.");
                sessionStorage.clear();
                window.location.href = "login.html";
                throw new Error("Unauthorized");
            }
            return res;
        }

// пример для POST:
        async function createProduct() {
            if (!isAdmin()) {
                alert("У вас недостаточно прав для добавления записи.");
                return;
            }

Установка

dotnet add package UniversalCrudLibrary
Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.3.6 120 6/16/2026
1.3.5 111 5/31/2026
1.3.4 115 5/24/2026
1.3.3 115 5/21/2026
1.3.2 112 5/20/2026
1.3.1 114 5/19/2026
1.3.0 113 5/18/2026
1.2.0 107 5/14/2026
1.1.2 101 5/6/2026
1.1.1 109 4/8/2026
1.1.0 106 4/7/2026
1.0.4 124 2/25/2026
1.0.3 206 10/2/2025
1.0.2 216 9/4/2025
1.0.1 152 6/20/2025