TheLim148 hace 2 semanas
commit
845f153092
Se han modificado 26 ficheros con 3600 adiciones y 0 borrados
  1. 25 0
      .gitignore
  2. BIN
      ComputerSalesSetup.exe
  3. 53 0
      ComputerSalesSetup.iss
  4. 21 0
      Computer_Sales_PM11/computer_sales/ComputerSalesApp.sln
  5. 16 0
      Computer_Sales_PM11/computer_sales/ComputerSalesApp/ComputerSalesApp.csproj
  6. 20 0
      Computer_Sales_PM11/computer_sales/ComputerSalesApp/Data/ConnectionSettings.cs
  7. 37 0
      Computer_Sales_PM11/computer_sales/ComputerSalesApp/Data/Db.cs
  8. 476 0
      Computer_Sales_PM11/computer_sales/ComputerSalesApp/Data/TableCatalog.cs
  9. 48 0
      Computer_Sales_PM11/computer_sales/ComputerSalesApp/Data/TableDefinition.cs
  10. 378 0
      Computer_Sales_PM11/computer_sales/ComputerSalesApp/Data/TableRepository.cs
  11. 9 0
      Computer_Sales_PM11/computer_sales/ComputerSalesApp/Models/ComboItem.cs
  12. 11 0
      Computer_Sales_PM11/computer_sales/ComputerSalesApp/Models/UserSession.cs
  13. 39 0
      Computer_Sales_PM11/computer_sales/ComputerSalesApp/Program.cs
  14. 42 0
      Computer_Sales_PM11/computer_sales/ComputerSalesApp/Services/AuthService.cs
  15. 83 0
      Computer_Sales_PM11/computer_sales/ComputerSalesApp/Services/ErrorHandler.cs
  16. 8 0
      Computer_Sales_PM11/computer_sales/ComputerSalesApp/Services/UserFriendlyException.cs
  17. 64 0
      Computer_Sales_PM11/computer_sales/ComputerSalesApp/UI/AppTheme.cs
  18. 591 0
      Computer_Sales_PM11/computer_sales/ComputerSalesApp/UI/EditRecordForm.cs
  19. 120 0
      Computer_Sales_PM11/computer_sales/ComputerSalesApp/UI/EditRecordForm.resx
  20. 189 0
      Computer_Sales_PM11/computer_sales/ComputerSalesApp/UI/LoginForm.cs
  21. 120 0
      Computer_Sales_PM11/computer_sales/ComputerSalesApp/UI/LoginForm.resx
  22. 751 0
      Computer_Sales_PM11/computer_sales/ComputerSalesApp/UI/MainForm.cs
  23. 120 0
      Computer_Sales_PM11/computer_sales/ComputerSalesApp/UI/MainForm.resx
  24. 70 0
      Computer_Sales_PM11/computer_sales/README.md
  25. 105 0
      README.md
  26. 204 0
      Руководство оператора.md

+ 25 - 0
.gitignore

@@ -0,0 +1,25 @@
+# Visual Studio
+.vs/
+*.user
+*.suo
+
+# Build results
+bin/
+obj/
+publish-win-x64/
+
+# Debug and temporary files
+*.log
+*.tmp
+*.pdb
+
+# Documents from PM11
+*.docx
+*.pptx
+
+# Database
+database/
+csv/
+
+# Screenshots and temporary images
+*.png

BIN
ComputerSalesSetup.exe


+ 53 - 0
ComputerSalesSetup.iss

@@ -0,0 +1,53 @@
+; Script generated by the Inno Setup Script Wizard.
+; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
+; Non-commercial use only
+
+#define MyAppName "Продажа компьютерной техники"
+#define MyAppVersion "1.0"
+#define MyAppPublisher "ПТК"
+#define MyAppExeName "ComputerSalesApp.exe"
+
+[Setup]
+; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
+; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
+AppId={{227FB96A-6CA1-4D4A-BFC0-A8D1547A789A}
+AppName={#MyAppName}
+AppVersion={#MyAppVersion}
+;AppVerName={#MyAppName} {#MyAppVersion}
+AppPublisher={#MyAppPublisher}
+DefaultDirName={autopf}\{#MyAppName}
+UninstallDisplayIcon={app}\{#MyAppExeName}
+; "ArchitecturesAllowed=x64compatible" specifies that Setup cannot run
+; on anything but x64 and Windows 11 on Arm.
+ArchitecturesAllowed=x64compatible
+; "ArchitecturesInstallIn64BitMode=x64compatible" requests that the
+; install be done in "64-bit mode" on x64 or Windows 11 on Arm,
+; meaning it should use the native 64-bit Program Files directory and
+; the 64-bit view of the registry.
+ArchitecturesInstallIn64BitMode=x64compatible
+DisableProgramGroupPage=yes
+; Uncomment the following line to run in non administrative install mode (install for current user only).
+;PrivilegesRequired=lowest
+OutputDir=C:\Users\Admin\Desktop\Учёба\2025-2026\ЗАЩИТА\installer
+OutputBaseFilename=ComputerSalesSetup
+SolidCompression=yes
+WizardStyle=modern dynamic
+
+[Languages]
+Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
+
+[Tasks]
+Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
+
+[Files]
+Source: "C:\Users\Admin\Desktop\Учёба\2025-2026\ЗАЩИТА\КОТЯ\computer_sales\ComputerSalesApp\publish-win-x64\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
+Source: "C:\Users\Admin\Desktop\Учёба\2025-2026\ЗАЩИТА\КОТЯ\computer_sales\ComputerSalesApp\publish-win-x64\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
+; NOTE: Don't use "Flags: ignoreversion" on any shared system files
+
+[Icons]
+Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
+Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
+
+[Run]
+Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
+

+ 21 - 0
Computer_Sales_PM11/computer_sales/ComputerSalesApp.sln

@@ -0,0 +1,21 @@
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ComputerSalesApp", "ComputerSalesApp\ComputerSalesApp.csproj", "{85B38A60-2C82-4E75-8E28-624F1195C9FB}"
+EndProject
+Global
+	GlobalSection(SolutionConfigurationPlatforms) = preSolution
+		Debug|Any CPU = Debug|Any CPU
+		Release|Any CPU = Release|Any CPU
+	EndGlobalSection
+	GlobalSection(ProjectConfigurationPlatforms) = postSolution
+		{85B38A60-2C82-4E75-8E28-624F1195C9FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{85B38A60-2C82-4E75-8E28-624F1195C9FB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{85B38A60-2C82-4E75-8E28-624F1195C9FB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{85B38A60-2C82-4E75-8E28-624F1195C9FB}.Release|Any CPU.Build.0 = Release|Any CPU
+	EndGlobalSection
+	GlobalSection(SolutionProperties) = preSolution
+		HideSolutionNode = FALSE
+	EndGlobalSection
+EndGlobal

+ 16 - 0
Computer_Sales_PM11/computer_sales/ComputerSalesApp/ComputerSalesApp.csproj

@@ -0,0 +1,16 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <OutputType>WinExe</OutputType>
+    <TargetFramework>net8.0-windows</TargetFramework>
+    <UseWindowsForms>true</UseWindowsForms>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>disable</Nullable>
+    <AssemblyName>ComputerSalesApp</AssemblyName>
+    <RootNamespace>ComputerSalesApp</RootNamespace>
+    <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Npgsql" Version="8.0.5" />
+  </ItemGroup>
+</Project>

+ 20 - 0
Computer_Sales_PM11/computer_sales/ComputerSalesApp/Data/ConnectionSettings.cs

@@ -0,0 +1,20 @@
+namespace ComputerSalesApp.Data;
+
+internal static class ConnectionSettings
+{
+    public const string Schema = "ryzhov_ka";
+
+    public const string ConnectionString =
+        "Host=edu-pg.itiscaf.ru;" +
+        "Port=5432;" +
+        "Database=dbwork;" +
+        "Username=ryzhov_ka;" +
+        "Password=SQ%mfgrCC3&;" +
+        "Search Path=ryzhov_ka,public;" +
+        "Pooling=true;" +
+        "Timeout=15;" +
+        "Command Timeout=30;" +
+        "Include Error Detail=true";
+
+    public static string FullName(string objectName) => $"{Schema}.{objectName}";
+}

+ 37 - 0
Computer_Sales_PM11/computer_sales/ComputerSalesApp/Data/Db.cs

@@ -0,0 +1,37 @@
+using System.Data;
+using Npgsql;
+
+namespace ComputerSalesApp.Data;
+
+internal static class Db
+{
+    public static NpgsqlConnection CreateConnection()
+    {
+        return new NpgsqlConnection(ConnectionSettings.ConnectionString);
+    }
+
+    public static async Task<DataTable> QueryAsync(string sql, Action<NpgsqlParameterCollection>? configureParameters = null)
+    {
+        await using var connection = CreateConnection();
+        await connection.OpenAsync();
+
+        await using var command = new NpgsqlCommand(sql, connection);
+        configureParameters?.Invoke(command.Parameters);
+
+        await using var reader = await command.ExecuteReaderAsync();
+        var table = new DataTable();
+        table.Load(reader);
+        return table;
+    }
+
+    public static async Task<int> ExecuteAsync(string sql, Action<NpgsqlParameterCollection>? configureParameters = null)
+    {
+        await using var connection = CreateConnection();
+        await connection.OpenAsync();
+
+        await using var command = new NpgsqlCommand(sql, connection);
+        configureParameters?.Invoke(command.Parameters);
+
+        return await command.ExecuteNonQueryAsync();
+    }
+}

+ 476 - 0
Computer_Sales_PM11/computer_sales/ComputerSalesApp/Data/TableCatalog.cs

@@ -0,0 +1,476 @@
+using ComputerSalesApp.Models;
+
+namespace ComputerSalesApp.Data;
+
+internal static class TableCatalog
+{
+    private static string S => ConnectionSettings.Schema;
+
+    public static List<TableDefinition> CreateTables(UserSession session)
+    {
+        if (!session.IsAdmin)
+        {
+            return new List<TableDefinition>
+            {
+                CustomerProfile(session),
+                Products(),
+                Orders(session),
+                OrderItems(session)
+            };
+        }
+
+        return new List<TableDefinition>
+        {
+            Warehouses(),
+            Employees(),
+            Customers(),
+            AppUsers(),
+            EmployeeWarehouses(),
+            Products(),
+            Inventory(),
+            Orders(session),
+            OrderItems(session),
+            AuditLog()
+        };
+    }
+
+    private static TableDefinition Warehouses() => new()
+    {
+        TableName = "warehouses",
+        DisplayName = "Склады",
+        IdColumn = "warehouse_id",
+        SelectSql = $@"
+select warehouse_id, warehouse_number, address, capacity
+from {S}.warehouses",
+        OrderBy = "warehouse_number",
+        Fields = new()
+        {
+            new() { ColumnName = "warehouse_number", Header = "Номер склада", Kind = FieldKind.Text, Required = true, MaxLength = 30 },
+            new() { ColumnName = "address", Header = "Адрес", Kind = FieldKind.Text, Required = true },
+            new() { ColumnName = "capacity", Header = "Вместимость", Kind = FieldKind.Integer, Required = true, MinValue = 0 }
+        },
+        Headers = new()
+        {
+            ["warehouse_number"] = "Номер склада",
+            ["address"] = "Адрес",
+            ["capacity"] = "Вместимость"
+        },
+        HiddenColumns = new() { "warehouse_id" }
+    };
+
+    private static TableDefinition Employees() => new()
+    {
+        TableName = "employees",
+        DisplayName = "Сотрудники",
+        IdColumn = "employee_id",
+        SelectSql = $@"
+select employee_id, last_name, first_name, middle_name, position_name, birth_date
+from {S}.employees",
+        OrderBy = "last_name, first_name",
+        Fields = new()
+        {
+            new() { ColumnName = "last_name", Header = "Фамилия", Kind = FieldKind.Text, Required = true, MaxLength = 80 },
+            new() { ColumnName = "first_name", Header = "Имя", Kind = FieldKind.Text, Required = true, MaxLength = 80 },
+            new() { ColumnName = "middle_name", Header = "Отчество", Kind = FieldKind.Text, MaxLength = 80, Nullable = true },
+            new() { ColumnName = "position_name", Header = "Должность", Kind = FieldKind.Text, Required = true, MaxLength = 100 },
+            new() { ColumnName = "birth_date", Header = "Дата рождения", Kind = FieldKind.Date, Required = true }
+        },
+        Headers = new()
+        {
+            ["last_name"] = "Фамилия",
+            ["first_name"] = "Имя",
+            ["middle_name"] = "Отчество",
+            ["position_name"] = "Должность",
+            ["birth_date"] = "Дата рождения"
+        },
+        HiddenColumns = new() { "employee_id" }
+    };
+
+    private static TableDefinition AppUsers() => new()
+    {
+        TableName = "app_users",
+        DisplayName = "Пользователи",
+        IdColumn = "user_id",
+        AdminOnly = true,
+        SelectSql = $@"
+select
+    au.user_id,
+    au.login,
+    au.role::text as role,
+    au.is_active,
+    au.employee_id,
+    concat_ws(' ', e.last_name, e.first_name, e.middle_name) as employee_name,
+    au.customer_id,
+    concat_ws(' ', c.last_name, c.first_name, c.middle_name) as customer_name
+from {S}.app_users au
+left join {S}.employees e on e.employee_id = au.employee_id
+left join {S}.customers c on c.customer_id = au.customer_id",
+        OrderBy = "login",
+        Fields = new()
+        {
+            new() { ColumnName = "login", Header = "Логин", Kind = FieldKind.Text, Required = true, MaxLength = 80 },
+            new() { ColumnName = "password", Header = "Пароль", Kind = FieldKind.Password, Required = true, MaxLength = 100 },
+            new() { ColumnName = "role", Header = "Роль", Kind = FieldKind.Role, Required = true, Items = new[] { "admin", "user" } },
+            new() { ColumnName = "is_active", Header = "Активен", Kind = FieldKind.Boolean, Required = true },
+            new()
+            {
+                ColumnName = "employee_id",
+                Header = "Сотрудник",
+                Kind = FieldKind.Combo,
+                Nullable = true,
+                DisplayColumnName = "employee_name",
+                ReferenceSql = $"select employee_id as id, concat_ws(' ', last_name, first_name, middle_name) as text from {S}.employees order by text"
+            },
+            new()
+            {
+                ColumnName = "customer_id",
+                Header = "Покупатель",
+                Kind = FieldKind.Combo,
+                Nullable = true,
+                DisplayColumnName = "customer_name",
+                ReferenceSql = $"select customer_id as id, concat_ws(' ', last_name, first_name, middle_name) as text from {S}.customers order by text"
+            }
+        },
+        Headers = new()
+        {
+            ["login"] = "Логин",
+            ["role"] = "Роль",
+            ["is_active"] = "Активен",
+            ["employee_name"] = "Сотрудник",
+            ["customer_name"] = "Покупатель"
+        },
+        HiddenColumns = new() { "user_id", "employee_id", "customer_id" }
+    };
+
+    private static TableDefinition EmployeeWarehouses() => new()
+    {
+        TableName = "employee_warehouses",
+        DisplayName = "Сотрудники на складах",
+        IdColumn = "employee_warehouse_id",
+        SelectSql = $@"
+select
+    ew.employee_warehouse_id,
+    ew.employee_id,
+    concat_ws(' ', e.last_name, e.first_name, e.middle_name) as employee_name,
+    ew.warehouse_id,
+    w.warehouse_number || ' — ' || w.address as warehouse_name,
+    ew.start_date
+from {S}.employee_warehouses ew
+join {S}.employees e on e.employee_id = ew.employee_id
+join {S}.warehouses w on w.warehouse_id = ew.warehouse_id",
+        OrderBy = "employee_name, warehouse_name",
+        Fields = new()
+        {
+            new()
+            {
+                ColumnName = "employee_id",
+                Header = "Сотрудник",
+                Kind = FieldKind.Combo,
+                Required = true,
+                DisplayColumnName = "employee_name",
+                ReferenceSql = $"select employee_id as id, concat_ws(' ', last_name, first_name, middle_name) as text from {S}.employees order by text"
+            },
+            new()
+            {
+                ColumnName = "warehouse_id",
+                Header = "Склад",
+                Kind = FieldKind.Combo,
+                Required = true,
+                DisplayColumnName = "warehouse_name",
+                ReferenceSql = $"select warehouse_id as id, warehouse_number || ' — ' || address as text from {S}.warehouses order by text"
+            },
+            new() { ColumnName = "start_date", Header = "Дата начала работы", Kind = FieldKind.Date, Required = true }
+        },
+        Headers = new()
+        {
+            ["employee_name"] = "Сотрудник",
+            ["warehouse_name"] = "Склад",
+            ["start_date"] = "Дата начала работы"
+        },
+        HiddenColumns = new() { "employee_warehouse_id", "employee_id", "warehouse_id" }
+    };
+
+    private static TableDefinition Customers() => CustomerBase("Покупатели", null);
+
+    private static TableDefinition CustomerProfile(UserSession session) => CustomerBase("Мои данные", session.CustomerId);
+
+    private static TableDefinition CustomerBase(string displayName, int? customerId)
+    {
+        string where = customerId.HasValue ? $"\nwhere customer_id = {customerId.Value}" : string.Empty;
+        return new TableDefinition
+        {
+            TableName = "customers",
+            DisplayName = displayName,
+            IdColumn = "customer_id",
+            SelectSql = $@"
+select customer_id, last_name, first_name, middle_name, address, phone
+from {S}.customers{where}",
+            OrderBy = "last_name, first_name",
+            Fields = new()
+            {
+                new() { ColumnName = "last_name", Header = "Фамилия", Kind = FieldKind.Text, Required = true, MaxLength = 80 },
+                new() { ColumnName = "first_name", Header = "Имя", Kind = FieldKind.Text, Required = true, MaxLength = 80 },
+                new() { ColumnName = "middle_name", Header = "Отчество", Kind = FieldKind.Text, MaxLength = 80, Nullable = true },
+                new() { ColumnName = "address", Header = "Адрес", Kind = FieldKind.Text, Nullable = true },
+                new() { ColumnName = "phone", Header = "Телефон", Kind = FieldKind.Text, MaxLength = 20, Nullable = true }
+            },
+            Headers = new()
+            {
+                ["last_name"] = "Фамилия",
+                ["first_name"] = "Имя",
+                ["middle_name"] = "Отчество",
+                ["address"] = "Адрес",
+                ["phone"] = "Телефон"
+            },
+            HiddenColumns = new() { "customer_id" }
+        };
+    }
+
+    private static TableDefinition Products() => new()
+    {
+        TableName = "products",
+        DisplayName = "Товары",
+        IdColumn = "product_id",
+        SelectSql = $@"
+select product_id, product_name, manufacturer, characteristics, price
+from {S}.products",
+        OrderBy = "product_name",
+        Fields = new()
+        {
+            new() { ColumnName = "product_name", Header = "Наименование", Kind = FieldKind.Text, Required = true, MaxLength = 150 },
+            new() { ColumnName = "manufacturer", Header = "Изготовитель", Kind = FieldKind.Text, Required = true, MaxLength = 120 },
+            new() { ColumnName = "characteristics", Header = "Характеристики", Kind = FieldKind.Text, Nullable = true },
+            new() { ColumnName = "price", Header = "Цена", Kind = FieldKind.Decimal, Required = true, MinValue = 0 }
+        },
+        Headers = new()
+        {
+            ["product_name"] = "Наименование",
+            ["manufacturer"] = "Изготовитель",
+            ["characteristics"] = "Характеристики",
+            ["price"] = "Цена"
+        },
+        HiddenColumns = new() { "product_id" }
+    };
+
+    private static TableDefinition Inventory() => new()
+    {
+        TableName = "inventory",
+        DisplayName = "Остатки на складах",
+        IdColumn = "inventory_id",
+        SelectSql = $@"
+select
+    i.inventory_id,
+    i.warehouse_id,
+    w.warehouse_number || ' — ' || w.address as warehouse_name,
+    i.product_id,
+    p.product_name || ' — ' || p.manufacturer as product_name,
+    i.quantity
+from {S}.inventory i
+join {S}.warehouses w on w.warehouse_id = i.warehouse_id
+join {S}.products p on p.product_id = i.product_id",
+        OrderBy = "warehouse_name, product_name",
+        Fields = new()
+        {
+            new()
+            {
+                ColumnName = "warehouse_id",
+                Header = "Склад",
+                Kind = FieldKind.Combo,
+                Required = true,
+                DisplayColumnName = "warehouse_name",
+                ReferenceSql = $"select warehouse_id as id, warehouse_number || ' — ' || address as text from {S}.warehouses order by text"
+            },
+            new()
+            {
+                ColumnName = "product_id",
+                Header = "Товар",
+                Kind = FieldKind.Combo,
+                Required = true,
+                DisplayColumnName = "product_name",
+                ReferenceSql = $"select product_id as id, product_name || ' — ' || manufacturer as text from {S}.products order by text"
+            },
+            new() { ColumnName = "quantity", Header = "Количество", Kind = FieldKind.Integer, Required = true, MinValue = 0 }
+        },
+        Headers = new()
+        {
+            ["warehouse_name"] = "Склад",
+            ["product_name"] = "Товар",
+            ["quantity"] = "Количество"
+        },
+        HiddenColumns = new() { "inventory_id", "warehouse_id", "product_id" }
+    };
+
+    private static TableDefinition Orders(UserSession session)
+    {
+        string where = session.IsAdmin ? string.Empty : $"\nwhere o.customer_id = {session.CustomerId ?? -1}";
+        return new TableDefinition
+        {
+            TableName = "orders",
+            DisplayName = session.IsAdmin ? "Заказы" : "Мои заказы",
+            IdColumn = "order_id",
+            SelectSql = $@"
+select
+    o.order_id,
+    o.customer_id,
+    concat_ws(' ', c.last_name, c.first_name, c.middle_name) as customer_name,
+    o.employee_id,
+    concat_ws(' ', e.last_name, e.first_name, e.middle_name) as employee_name,
+    o.warehouse_id,
+    w.warehouse_number || ' — ' || w.address as warehouse_name,
+    o.order_date,
+    o.status,
+    coalesce(sum(oi.quantity * oi.unit_price), 0)::numeric(12,2) as order_total
+from {S}.orders o
+join {S}.customers c on c.customer_id = o.customer_id
+join {S}.employees e on e.employee_id = o.employee_id
+join {S}.warehouses w on w.warehouse_id = o.warehouse_id
+left join {S}.order_items oi on oi.order_id = o.order_id{where}
+group by
+    o.order_id,
+    o.customer_id,
+    c.last_name, c.first_name, c.middle_name,
+    o.employee_id,
+    e.last_name, e.first_name, e.middle_name,
+    o.warehouse_id,
+    w.warehouse_number, w.address,
+    o.order_date,
+    o.status",
+            OrderBy = "order_date desc, order_id desc",
+            Fields = new()
+            {
+                new()
+                {
+                    ColumnName = "customer_id",
+                    Header = "Покупатель",
+                    Kind = FieldKind.Combo,
+                    Required = true,
+                    DisplayColumnName = "customer_name",
+                    ReferenceSql = $"select customer_id as id, concat_ws(' ', last_name, first_name, middle_name) as text from {S}.customers order by text"
+                },
+                new()
+                {
+                    ColumnName = "employee_id",
+                    Header = "Сотрудник",
+                    Kind = FieldKind.Combo,
+                    Required = true,
+                    DisplayColumnName = "employee_name",
+                    ReferenceSql = $"select employee_id as id, concat_ws(' ', last_name, first_name, middle_name) as text from {S}.employees order by text"
+                },
+                new()
+                {
+                    ColumnName = "warehouse_id",
+                    Header = "Склад",
+                    Kind = FieldKind.Combo,
+                    Required = true,
+                    DisplayColumnName = "warehouse_name",
+                    ReferenceSql = $"select warehouse_id as id, warehouse_number || ' — ' || address as text from {S}.warehouses order by text"
+                },
+                new() { ColumnName = "order_date", Header = "Дата заказа", Kind = FieldKind.Date, Required = true },
+                new() { ColumnName = "status", Header = "Статус", Kind = FieldKind.Status, Required = true, Items = new[] { "создан", "в работе", "выполнен", "отменён" } }
+            },
+            Headers = new()
+            {
+                ["customer_name"] = "Покупатель",
+                ["employee_name"] = "Сотрудник",
+                ["warehouse_name"] = "Склад",
+                ["order_date"] = "Дата заказа",
+                ["status"] = "Статус",
+                ["order_total"] = "Итоговая стоимость"
+            },
+            HiddenColumns = new() { "order_id", "customer_id", "employee_id", "warehouse_id" }
+        };
+    }
+
+    private static TableDefinition OrderItems(UserSession session)
+    {
+        string where = session.IsAdmin ? string.Empty : $"\nwhere o.customer_id = {session.CustomerId ?? -1}";
+        return new TableDefinition
+        {
+            TableName = "order_items",
+            DisplayName = session.IsAdmin ? "Товары в заказе" : "Товары в моих заказах",
+            IdColumn = "order_item_id",
+            SelectSql = $@"
+select
+    oi.order_item_id,
+    oi.order_id,
+    'Заказ №' || oi.order_id || ' от ' || to_char(o.order_date, 'DD.MM.YYYY') || ', склад ' || w.warehouse_number as order_name,
+    oi.product_id,
+    p.product_name || ' — ' || p.manufacturer as product_name,
+    oi.quantity,
+    oi.unit_price,
+    oi.quantity * oi.unit_price as item_total
+from {S}.order_items oi
+join {S}.orders o on o.order_id = oi.order_id
+join {S}.warehouses w on w.warehouse_id = o.warehouse_id
+join {S}.products p on p.product_id = oi.product_id{where}",
+            OrderBy = "order_id desc, product_name",
+            Fields = new()
+            {
+                new()
+                {
+                    ColumnName = "order_id",
+                    Header = "Заказ",
+                    Kind = FieldKind.Combo,
+                    Required = true,
+                    DisplayColumnName = "order_name",
+                    ReferenceSql = $"select o.order_id as id, 'Заказ №' || o.order_id || ' от ' || to_char(o.order_date, 'DD.MM.YYYY') || ', склад ' || w.warehouse_number as text from {S}.orders o join {S}.warehouses w on w.warehouse_id = o.warehouse_id order by o.order_id desc"
+                },
+                new()
+                {
+                    ColumnName = "product_id",
+                    Header = "Товар",
+                    Kind = FieldKind.Combo,
+                    Required = true,
+                    DisplayColumnName = "product_name",
+                    ReferenceSql = $"select product_id as id, product_name || ' — ' || manufacturer as text from {S}.products order by text"
+                },
+                new() { ColumnName = "quantity", Header = "Количество", Kind = FieldKind.Integer, Required = true, MinValue = 1 },
+                new() { ColumnName = "unit_price", Header = "Цена за единицу", Kind = FieldKind.Decimal, Required = true, MinValue = 0 }
+            },
+            Headers = new()
+            {
+                ["order_name"] = "Заказ",
+                ["product_name"] = "Товар",
+                ["quantity"] = "Количество",
+                ["unit_price"] = "Цена за единицу",
+                ["item_total"] = "Сумма"
+            },
+            HiddenColumns = new() { "order_item_id", "order_id", "product_id" }
+        };
+    }
+
+
+    private static TableDefinition AuditLog() => new()
+    {
+        TableName = "audit_log",
+        DisplayName = "Аудит действий",
+        IdColumn = "audit_id",
+        AdminOnly = true,
+        ReadOnly = true,
+        SelectSql = $@"
+select
+    audit_id,
+    changed_at,
+    db_user,
+    table_name,
+    operation,
+    record_id,
+    old_data::text as old_data,
+    new_data::text as new_data
+from {S}.audit_log",
+        OrderBy = "changed_at desc, audit_id desc",
+        Fields = new(),
+        Headers = new()
+        {
+            ["changed_at"] = "Дата и время",
+            ["db_user"] = "Пользователь БД",
+            ["table_name"] = "Таблица",
+            ["operation"] = "Операция",
+            ["record_id"] = "ID записи",
+            ["old_data"] = "Было",
+            ["new_data"] = "Стало"
+        },
+        HiddenColumns = new() { "audit_id" }
+    };
+
+}

+ 48 - 0
Computer_Sales_PM11/computer_sales/ComputerSalesApp/Data/TableDefinition.cs

@@ -0,0 +1,48 @@
+namespace ComputerSalesApp.Data;
+
+internal enum FieldKind
+{
+    Text,
+    Integer,
+    Decimal,
+    Date,
+    Boolean,
+    Combo,
+    Status,
+    Role,
+    Password
+}
+
+internal sealed class FieldDefinition
+{
+    public string ColumnName { get; init; } = string.Empty;
+    public string Header { get; init; } = string.Empty;
+    public FieldKind Kind { get; init; }
+    public bool Required { get; init; }
+    public bool Nullable { get; init; }
+    public int MaxLength { get; init; } = 0;
+    public decimal? MinValue { get; init; }
+    public decimal? MaxValue { get; init; }
+    public string? ReferenceSql { get; init; }
+    public string? DisplayColumnName { get; init; }
+    public string[]? Items { get; init; }
+
+    public bool IsPassword => Kind == FieldKind.Password;
+}
+
+internal sealed class TableDefinition
+{
+    public string TableName { get; init; } = string.Empty;
+    public string DisplayName { get; init; } = string.Empty;
+    public string IdColumn { get; init; } = string.Empty;
+    public string SelectSql { get; init; } = string.Empty;
+    public string OrderBy { get; init; } = string.Empty;
+    public bool AdminOnly { get; init; }
+    public bool ReadOnly { get; init; }
+    public bool IsUsersTable => TableName == "app_users";
+    public List<FieldDefinition> Fields { get; init; } = new();
+    public Dictionary<string, string> Headers { get; init; } = new();
+    public HashSet<string> HiddenColumns { get; init; } = new();
+
+    public override string ToString() => DisplayName;
+}

+ 378 - 0
Computer_Sales_PM11/computer_sales/ComputerSalesApp/Data/TableRepository.cs

@@ -0,0 +1,378 @@
+using System.Data;
+using ComputerSalesApp.Models;
+using ComputerSalesApp.Services;
+using Npgsql;
+
+namespace ComputerSalesApp.Data;
+
+internal sealed class TableRepository
+{
+    public Task<DataTable> LoadTableAsync(TableDefinition table)
+    {
+        string sql = table.SelectSql;
+        if (!string.IsNullOrWhiteSpace(table.OrderBy))
+        {
+            sql += $" order by {table.OrderBy}";
+        }
+
+        return Db.QueryAsync(sql);
+    }
+
+    public async Task<List<ComboItem>> LoadComboItemsAsync(FieldDefinition field)
+    {
+        if (string.IsNullOrWhiteSpace(field.ReferenceSql))
+        {
+            return new List<ComboItem>();
+        }
+
+        DataTable table = await Db.QueryAsync(field.ReferenceSql);
+        var items = new List<ComboItem>();
+
+        if (field.Nullable)
+        {
+            items.Add(new ComboItem { Id = null, Text = "(не выбрано)" });
+        }
+
+        foreach (DataRow row in table.Rows)
+        {
+            items.Add(new ComboItem
+            {
+                Id = row["id"] == DBNull.Value ? null : Convert.ToInt32(row["id"]),
+                Text = Convert.ToString(row["text"]) ?? string.Empty
+            });
+        }
+
+        return items;
+    }
+
+    public async Task<List<ComboItem>> LoadProductsForOrderAsync(int orderId, int? selectedProductId)
+    {
+        DataTable table = await Db.QueryAsync($@"
+select
+    p.product_id as id,
+    p.product_name || ' — ' || p.manufacturer || ' (в наличии: ' || i.quantity || ' шт.)' as text
+from {ConnectionSettings.Schema}.orders o
+join {ConnectionSettings.Schema}.inventory i on i.warehouse_id = o.warehouse_id
+join {ConnectionSettings.Schema}.products p on p.product_id = i.product_id
+where o.order_id = @order_id
+  and (i.quantity > 0 or p.product_id = @selected_product_id)
+order by p.product_name, p.manufacturer;", p =>
+        {
+            p.AddWithValue("order_id", orderId);
+            p.AddWithValue("selected_product_id", selectedProductId ?? 0);
+        });
+
+        var items = new List<ComboItem>();
+        foreach (DataRow row in table.Rows)
+        {
+            items.Add(new ComboItem
+            {
+                Id = row["id"] == DBNull.Value ? null : Convert.ToInt32(row["id"]),
+                Text = Convert.ToString(row["text"]) ?? string.Empty
+            });
+        }
+
+        return items;
+    }
+
+    public async Task<decimal> LoadProductPriceAsync(int productId)
+    {
+        DataTable table = await Db.QueryAsync($@"
+select price
+from {ConnectionSettings.Schema}.products
+where product_id = @product_id;", p => p.AddWithValue("product_id", productId));
+
+        if (table.Rows.Count == 0 || table.Rows[0]["price"] == DBNull.Value)
+        {
+            return 0;
+        }
+
+        return Convert.ToDecimal(table.Rows[0]["price"]);
+    }
+
+    public async Task InsertAsync(TableDefinition table, Dictionary<string, object?> values)
+    {
+        if (table.IsUsersTable)
+        {
+            await InsertUserAsync(values);
+            return;
+        }
+
+        if (table.TableName == "order_items")
+        {
+            await ValidateOrderItemAvailabilityAsync(values, null);
+        }
+
+        var fields = table.Fields.Where(f => !f.IsPassword).ToList();
+        string columns = string.Join(", ", fields.Select(f => f.ColumnName));
+        string parameters = string.Join(", ", fields.Select(f => "@" + f.ColumnName));
+
+        string sql = $"insert into {ConnectionSettings.Schema}.{table.TableName} ({columns}) values ({parameters});";
+
+        int affected = await Db.ExecuteAsync(sql, p => AddParameters(p, fields, values));
+        if (table.TableName == "order_items" && affected == 0)
+        {
+            throw new UserFriendlyException("Позиция заказа не была добавлена. Проверьте наличие товара на выбранном складе.");
+        }
+    }
+
+    public async Task UpdateAsync(TableDefinition table, int id, Dictionary<string, object?> values)
+    {
+        if (table.IsUsersTable)
+        {
+            await UpdateUserAsync(id, values);
+            return;
+        }
+
+        if (table.TableName == "order_items")
+        {
+            await ValidateOrderItemAvailabilityAsync(values, id);
+        }
+
+        var fields = table.Fields.Where(f => !f.IsPassword).ToList();
+        string setClause = string.Join(", ", fields.Select(f => $"{f.ColumnName} = @{f.ColumnName}"));
+        string sql = $"update {ConnectionSettings.Schema}.{table.TableName} set {setClause} where {table.IdColumn} = @id;";
+
+        int affected = await Db.ExecuteAsync(sql, p =>
+        {
+            AddParameters(p, fields, values);
+            p.AddWithValue("id", id);
+        });
+        if (table.TableName == "order_items" && affected == 0)
+        {
+            throw new UserFriendlyException("Позиция заказа не была изменена. Проверьте наличие товара на выбранном складе.");
+        }
+    }
+
+    public Task DeleteAsync(TableDefinition table, int id)
+    {
+        string sql = $"delete from {ConnectionSettings.Schema}.{table.TableName} where {table.IdColumn} = @id;";
+        return Db.ExecuteAsync(sql, p => p.AddWithValue("id", id));
+    }
+
+    public Task<DataTable> LoadCustomersForReportAsync(UserSession session)
+    {
+        if (session.IsAdmin)
+        {
+            string adminSql = $@"
+select customer_id as id, concat_ws(' ', last_name, first_name, middle_name) as text
+from {ConnectionSettings.Schema}.customers
+order by text;";
+            return Db.QueryAsync(adminSql);
+        }
+
+        string userSql = $@"
+select customer_id as id, concat_ws(' ', last_name, first_name, middle_name) as text
+from {ConnectionSettings.Schema}.customers
+where customer_id = @customer_id
+order by text;";
+        return Db.QueryAsync(userSql, p => p.AddWithValue("customer_id", session.CustomerId ?? -1));
+    }
+
+    public async Task<DateTime?> LoadLatestOrderDateAsync(int customerId)
+    {
+        string sql = $@"
+select max(order_date) as order_date
+from {ConnectionSettings.Schema}.orders
+where customer_id = @customer_id;";
+
+        DataTable table = await Db.QueryAsync(sql, p => p.AddWithValue("customer_id", customerId));
+        if (table.Rows.Count == 0 || table.Rows[0]["order_date"] == DBNull.Value)
+        {
+            return null;
+        }
+
+        return Convert.ToDateTime(table.Rows[0]["order_date"]);
+    }
+
+    public Task<DataTable> LoadCustomerOrderItemsAsync(UserSession session, int requestedCustomerId, DateTime orderDate, bool descending)
+    {
+        int customerId = session.IsAdmin ? requestedCustomerId : session.CustomerId ?? -1;
+        string direction = descending ? "desc" : "asc";
+        string sql = $@"
+select
+    o.order_id,
+    o.order_date,
+    concat_ws(' ', c.last_name, c.first_name, c.middle_name) as customer_full_name,
+    concat_ws(' ', e.last_name, e.first_name, e.middle_name) as employee_full_name,
+    p.product_name,
+    p.manufacturer,
+    oi.quantity,
+    oi.unit_price,
+    (oi.quantity * oi.unit_price)::numeric(12,2) as item_total,
+    o.status
+from {ConnectionSettings.Schema}.orders o
+join {ConnectionSettings.Schema}.customers c on c.customer_id = o.customer_id
+join {ConnectionSettings.Schema}.employees e on e.employee_id = o.employee_id
+join {ConnectionSettings.Schema}.order_items oi on oi.order_id = o.order_id
+join {ConnectionSettings.Schema}.products p on p.product_id = oi.product_id
+where o.customer_id = @customer_id
+  and o.order_date = cast(@order_date as date)
+order by oi.quantity {direction};";
+
+        return Db.QueryAsync(sql, p =>
+        {
+            p.AddWithValue("customer_id", customerId);
+            p.AddWithValue("order_date", orderDate.Date);
+        });
+    }
+
+
+    private static async Task ValidateOrderItemAvailabilityAsync(Dictionary<string, object?> values, int? orderItemId)
+    {
+        int orderId = Convert.ToInt32(values["order_id"] ?? 0);
+        int productId = Convert.ToInt32(values["product_id"] ?? 0);
+        int quantity = Convert.ToInt32(values["quantity"] ?? 0);
+
+        if (orderId <= 0 || productId <= 0 || quantity <= 0)
+        {
+            throw new UserFriendlyException("Выберите заказ, товар и укажите количество больше нуля.");
+        }
+
+        int warehouseId = await GetOrderWarehouseIdAsync(orderId);
+        int available = await GetAvailableQuantityAsync(warehouseId, productId);
+
+        if (orderItemId.HasValue)
+        {
+            DataTable oldItem = await Db.QueryAsync($@"
+select
+    oi.order_id,
+    o.warehouse_id,
+    oi.product_id,
+    oi.quantity
+from {ConnectionSettings.Schema}.order_items oi
+join {ConnectionSettings.Schema}.orders o on o.order_id = oi.order_id
+where oi.order_item_id = @order_item_id;", p => p.AddWithValue("order_item_id", orderItemId.Value));
+
+            if (oldItem.Rows.Count == 0)
+            {
+                throw new UserFriendlyException("Выбранная позиция заказа не найдена. Обновите таблицу и попробуйте снова.");
+            }
+
+            DataRow row = oldItem.Rows[0];
+            int oldWarehouseId = Convert.ToInt32(row["warehouse_id"]);
+            int oldProductId = Convert.ToInt32(row["product_id"]);
+            int oldQuantity = Convert.ToInt32(row["quantity"]);
+
+            if (oldWarehouseId == warehouseId && oldProductId == productId)
+            {
+                available += oldQuantity;
+            }
+        }
+
+        if (available <= 0)
+        {
+            throw new UserFriendlyException("На выбранном складе нет этого товара.");
+        }
+
+        if (quantity > available)
+        {
+            throw new UserFriendlyException($"Недостаточно товара на складе.\n\nДоступно: {available}\nЗапрошено: {quantity}");
+        }
+    }
+
+    private static async Task<int> GetOrderWarehouseIdAsync(int orderId)
+    {
+        DataTable table = await Db.QueryAsync($@"
+select warehouse_id
+from {ConnectionSettings.Schema}.orders
+where order_id = @order_id;", p => p.AddWithValue("order_id", orderId));
+
+        if (table.Rows.Count == 0 || table.Rows[0]["warehouse_id"] == DBNull.Value)
+        {
+            throw new UserFriendlyException("У выбранного заказа не указан склад. Откройте заказ и выберите склад.");
+        }
+
+        return Convert.ToInt32(table.Rows[0]["warehouse_id"]);
+    }
+
+    private static async Task<int> GetAvailableQuantityAsync(int warehouseId, int productId)
+    {
+        DataTable table = await Db.QueryAsync($@"
+select quantity
+from {ConnectionSettings.Schema}.inventory
+where warehouse_id = @warehouse_id
+  and product_id = @product_id;", p =>
+        {
+            p.AddWithValue("warehouse_id", warehouseId);
+            p.AddWithValue("product_id", productId);
+        });
+
+        if (table.Rows.Count == 0 || table.Rows[0]["quantity"] == DBNull.Value)
+        {
+            return 0;
+        }
+
+        return Convert.ToInt32(table.Rows[0]["quantity"]);
+    }
+
+    private static void AddParameters(NpgsqlParameterCollection parameters, IEnumerable<FieldDefinition> fields, Dictionary<string, object?> values)
+    {
+        foreach (FieldDefinition field in fields)
+        {
+            object? value = values.TryGetValue(field.ColumnName, out object? rawValue) ? rawValue : null;
+            parameters.AddWithValue(field.ColumnName, value ?? DBNull.Value);
+        }
+    }
+
+    private static async Task InsertUserAsync(Dictionary<string, object?> values)
+    {
+        string sql = $@"
+insert into {ConnectionSettings.Schema}.app_users (login, password_hash, role, is_active, employee_id, customer_id)
+values (@login, public.crypt(@password, public.gen_salt('bf')), @role::{ConnectionSettings.Schema}.app_role, @is_active, @employee_id, @customer_id);";
+
+        await Db.ExecuteAsync(sql, p =>
+        {
+            p.AddWithValue("login", values["login"] ?? DBNull.Value);
+            p.AddWithValue("password", values["password"] ?? DBNull.Value);
+            p.AddWithValue("role", values["role"] ?? DBNull.Value);
+            p.AddWithValue("is_active", values["is_active"] ?? true);
+            p.AddWithValue("employee_id", values["employee_id"] ?? DBNull.Value);
+            p.AddWithValue("customer_id", values["customer_id"] ?? DBNull.Value);
+        });
+    }
+
+    private static async Task UpdateUserAsync(int id, Dictionary<string, object?> values)
+    {
+        string password = Convert.ToString(values.GetValueOrDefault("password")) ?? string.Empty;
+
+        string sql;
+        if (string.IsNullOrWhiteSpace(password))
+        {
+            sql = $@"
+update {ConnectionSettings.Schema}.app_users
+set login = @login,
+    role = @role::{ConnectionSettings.Schema}.app_role,
+    is_active = @is_active,
+    employee_id = @employee_id,
+    customer_id = @customer_id
+where user_id = @id;";
+        }
+        else
+        {
+            sql = $@"
+update {ConnectionSettings.Schema}.app_users
+set login = @login,
+    password_hash = public.crypt(@password, public.gen_salt('bf')),
+    role = @role::{ConnectionSettings.Schema}.app_role,
+    is_active = @is_active,
+    employee_id = @employee_id,
+    customer_id = @customer_id
+where user_id = @id;";
+        }
+
+        await Db.ExecuteAsync(sql, p =>
+        {
+            p.AddWithValue("id", id);
+            p.AddWithValue("login", values["login"] ?? DBNull.Value);
+            p.AddWithValue("role", values["role"] ?? DBNull.Value);
+            p.AddWithValue("is_active", values["is_active"] ?? true);
+            p.AddWithValue("employee_id", values["employee_id"] ?? DBNull.Value);
+            p.AddWithValue("customer_id", values["customer_id"] ?? DBNull.Value);
+            if (!string.IsNullOrWhiteSpace(password))
+            {
+                p.AddWithValue("password", password);
+            }
+        });
+    }
+}

+ 9 - 0
Computer_Sales_PM11/computer_sales/ComputerSalesApp/Models/ComboItem.cs

@@ -0,0 +1,9 @@
+namespace ComputerSalesApp.Models;
+
+internal sealed class ComboItem
+{
+    public int? Id { get; init; }
+    public string Text { get; init; } = string.Empty;
+
+    public override string ToString() => Text;
+}

+ 11 - 0
Computer_Sales_PM11/computer_sales/ComputerSalesApp/Models/UserSession.cs

@@ -0,0 +1,11 @@
+namespace ComputerSalesApp.Models;
+
+internal sealed class UserSession
+{
+    public int UserId { get; init; }
+    public string Login { get; init; } = string.Empty;
+    public string Role { get; init; } = string.Empty;
+    public int? CustomerId { get; init; }
+
+    public bool IsAdmin => string.Equals(Role, "admin", StringComparison.OrdinalIgnoreCase);
+}

+ 39 - 0
Computer_Sales_PM11/computer_sales/ComputerSalesApp/Program.cs

@@ -0,0 +1,39 @@
+using ComputerSalesApp.Services;
+using ComputerSalesApp.UI;
+
+namespace ComputerSalesApp;
+
+internal static class Program
+{
+    [STAThread]
+    private static void Main()
+    {
+        Application.SetHighDpiMode(HighDpiMode.SystemAware);
+        Application.EnableVisualStyles();
+        Application.SetCompatibleTextRenderingDefault(false);
+
+        try
+        {
+            bool restart;
+            do
+            {
+                restart = false;
+
+                using var loginForm = new LoginForm();
+                if (loginForm.ShowDialog() != DialogResult.OK || loginForm.Session == null)
+                {
+                    break;
+                }
+
+                using var mainForm = new MainForm(loginForm.Session);
+                Application.Run(mainForm);
+                restart = mainForm.LogoutRequested;
+            }
+            while (restart);
+        }
+        catch (Exception ex)
+        {
+            ErrorHandler.Show("Критическая ошибка", "Приложение не смогло продолжить работу.", ex);
+        }
+    }
+}

+ 42 - 0
Computer_Sales_PM11/computer_sales/ComputerSalesApp/Services/AuthService.cs

@@ -0,0 +1,42 @@
+using ComputerSalesApp.Data;
+using ComputerSalesApp.Models;
+using Npgsql;
+
+namespace ComputerSalesApp.Services;
+
+internal sealed class AuthService
+{
+    public async Task<UserSession?> AuthenticateAsync(string login, string password)
+    {
+        await using var connection = Db.CreateConnection();
+        await connection.OpenAsync();
+
+        string sql = $@"
+select
+    user_id,
+    login,
+    role::text as role,
+    customer_id
+from {ConnectionSettings.Schema}.fn_authenticate_user(@login, @password);";
+
+        await using var command = new NpgsqlCommand(sql, connection);
+        command.Parameters.AddWithValue("login", login);
+        command.Parameters.AddWithValue("password", password);
+
+        await using var reader = await command.ExecuteReaderAsync();
+        if (!await reader.ReadAsync())
+        {
+            return null;
+        }
+
+        return new UserSession
+        {
+            UserId = reader.GetInt32(reader.GetOrdinal("user_id")),
+            Login = reader.GetString(reader.GetOrdinal("login")),
+            Role = reader.GetString(reader.GetOrdinal("role")),
+            CustomerId = reader.IsDBNull(reader.GetOrdinal("customer_id"))
+                ? null
+                : reader.GetInt32(reader.GetOrdinal("customer_id"))
+        };
+    }
+}

+ 83 - 0
Computer_Sales_PM11/computer_sales/ComputerSalesApp/Services/ErrorHandler.cs

@@ -0,0 +1,83 @@
+using Npgsql;
+
+namespace ComputerSalesApp.Services;
+
+internal static class ErrorHandler
+{
+    public static void Show(string title, string userAction, Exception ex)
+    {
+        MessageBox.Show(
+            BuildMessage(userAction, ex),
+            title,
+            MessageBoxButtons.OK,
+            MessageBoxIcon.Error);
+    }
+
+    public static void ShowInfo(string title, string message)
+    {
+        MessageBox.Show(message, title, MessageBoxButtons.OK, MessageBoxIcon.Information);
+    }
+
+    public static bool Confirm(string message)
+    {
+        return MessageBox.Show(message, "Подтверждение", MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes;
+    }
+
+    private static string BuildMessage(string userAction, Exception ex)
+    {
+        Exception baseException = ex.GetBaseException();
+        string friendly = GetFriendlyText(baseException);
+
+        return $"{userAction}\n\n{friendly}\n\nТехнические подробности:\n{baseException.Message}";
+    }
+
+    private static string GetFriendlyText(Exception ex)
+    {
+        if (ex is UserFriendlyException)
+        {
+            return ex.Message;
+        }
+
+        if (ex is FileNotFoundException || ex is FileLoadException)
+        {
+            return "Не найдена или не загрузилась одна из библиотек приложения. " +
+                   "Откройте проект в Visual Studio, выполните Restore NuGet Packages и Rebuild Solution.";
+        }
+
+        if (ex is TimeoutException)
+        {
+            return "Превышено время ожидания операции. Проверьте интернет, доступность сервера PostgreSQL и попробуйте снова.";
+        }
+
+        if (ex is PostgresException pg)
+        {
+            return pg.SqlState switch
+            {
+                "23505" => "Такая запись уже существует. Проверьте поля, которые должны быть уникальными.",
+                "23503" => "Нельзя выполнить операцию, потому что запись связана с другими таблицами. Например, нельзя удалить товар, который уже есть в заказах или остатках.",
+                "23502" => "Не заполнено обязательное поле.",
+                "23514" => "Нарушено проверочное ограничение БД. Проверьте количество, цену, вместимость, дату рождения или статус.",
+                "22P02" => "Некорректный формат значения. Проверьте введённые числа и даты.",
+                "42883" => "В базе данных не найдена нужная функция. Проверьте, что выполнен SQL-скрипт создания функции авторизации.",
+                "42P01" => "В базе данных не найдена нужная таблица или представление. Проверьте, что выполнен SQL-скрипт создания таблиц.",
+                "42703" => "В базе данных не найден нужный столбец. Проверьте, что структура БД соответствует проекту.",
+                "3F000" => "Не найдена схема ryzhov_ka. Проверьте search_path и подключение под нужным пользователем.",
+                "28P01" => "Неверный логин или пароль для подключения к PostgreSQL.",
+                "42501" => "Недостаточно прав для выполнения операции в базе данных.",
+                _ => "PostgreSQL вернул ошибку при выполнении операции."
+            };
+        }
+
+        if (ex is NpgsqlException)
+        {
+            return "Не удалось выполнить запрос к PostgreSQL. Проверьте подключение к серверу, строку подключения и наличие объектов БД.";
+        }
+
+        if (ex is InvalidOperationException)
+        {
+            return "Операцию нельзя выполнить в текущем состоянии приложения. Проверьте выбранную запись и повторите попытку.";
+        }
+
+        return "Произошла непредвиденная ошибка приложения.";
+    }
+}

+ 8 - 0
Computer_Sales_PM11/computer_sales/ComputerSalesApp/Services/UserFriendlyException.cs

@@ -0,0 +1,8 @@
+namespace ComputerSalesApp.Services;
+
+internal sealed class UserFriendlyException : Exception
+{
+    public UserFriendlyException(string message) : base(message)
+    {
+    }
+}

+ 64 - 0
Computer_Sales_PM11/computer_sales/ComputerSalesApp/UI/AppTheme.cs

@@ -0,0 +1,64 @@
+namespace ComputerSalesApp.UI;
+
+internal static class AppTheme
+{
+    public static readonly Color Background = Color.FromArgb(232, 249, 249);
+    public static readonly Color Surface = Color.FromArgb(247, 254, 254);
+    public static readonly Color Header = Color.FromArgb(31, 139, 145);
+    public static readonly Color HeaderDark = Color.FromArgb(20, 105, 112);
+    public static readonly Color Accent = Color.FromArgb(39, 174, 181);
+    public static readonly Color AccentDark = Color.FromArgb(20, 132, 140);
+    public static readonly Color Border = Color.FromArgb(160, 222, 224);
+    public static readonly Color GridHeader = Color.FromArgb(207, 244, 244);
+    public static readonly Color GridAlt = Color.FromArgb(238, 252, 252);
+    public static readonly Color Text = Color.FromArgb(22, 55, 58);
+
+    public static Font Regular(float size = 10f) => new("Segoe UI", size, FontStyle.Regular);
+    public static Font Bold(float size = 10f) => new("Segoe UI", size, FontStyle.Bold);
+
+    public static void StylePrimaryButton(Button button)
+    {
+        button.BackColor = Accent;
+        button.ForeColor = Color.White;
+        button.FlatStyle = FlatStyle.Flat;
+        button.FlatAppearance.BorderColor = AccentDark;
+        button.FlatAppearance.BorderSize = 1;
+        button.Font = Bold(10f);
+        button.UseVisualStyleBackColor = false;
+        button.Cursor = Cursors.Hand;
+    }
+
+    public static void StyleSecondaryButton(Button button)
+    {
+        button.BackColor = Color.FromArgb(224, 247, 247);
+        button.ForeColor = Text;
+        button.FlatStyle = FlatStyle.Flat;
+        button.FlatAppearance.BorderColor = Border;
+        button.FlatAppearance.BorderSize = 1;
+        button.Font = Bold(10f);
+        button.UseVisualStyleBackColor = false;
+        button.Cursor = Cursors.Hand;
+    }
+
+    public static void StyleGrid(DataGridView grid)
+    {
+        grid.BackgroundColor = Surface;
+        grid.BorderStyle = BorderStyle.FixedSingle;
+        grid.GridColor = Border;
+        grid.EnableHeadersVisualStyles = false;
+        grid.ColumnHeadersDefaultCellStyle.BackColor = GridHeader;
+        grid.ColumnHeadersDefaultCellStyle.ForeColor = Text;
+        grid.ColumnHeadersDefaultCellStyle.Font = Bold(9.5f);
+        grid.ColumnHeadersDefaultCellStyle.SelectionBackColor = GridHeader;
+        grid.ColumnHeadersDefaultCellStyle.SelectionForeColor = Text;
+        grid.DefaultCellStyle.BackColor = Color.White;
+        grid.DefaultCellStyle.ForeColor = Text;
+        grid.DefaultCellStyle.SelectionBackColor = Color.FromArgb(178, 235, 236);
+        grid.DefaultCellStyle.SelectionForeColor = Text;
+        grid.AlternatingRowsDefaultCellStyle.BackColor = GridAlt;
+        grid.RowTemplate.Height = 28;
+        grid.ColumnHeadersHeight = 32;
+        grid.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill;
+        grid.RowHeadersVisible = false;
+    }
+}

+ 591 - 0
Computer_Sales_PM11/computer_sales/ComputerSalesApp/UI/EditRecordForm.cs

@@ -0,0 +1,591 @@
+using System.Data;
+using System.Globalization;
+using ComputerSalesApp.Data;
+using ComputerSalesApp.Models;
+using ComputerSalesApp.Services;
+
+namespace ComputerSalesApp.UI;
+
+internal sealed class EditRecordForm : Form
+{
+    private readonly TableDefinition _table;
+    private readonly DataRowView? _row;
+    private readonly bool _isEdit;
+    private readonly TableRepository _repository = new();
+    private readonly Dictionary<string, Control> _controls = new();
+    private readonly TableLayoutPanel _layout = new();
+    private readonly Button _saveButton = new();
+    private readonly Button _cancelButton = new();
+    private readonly TextBox _orderItemTotalBox = new();
+    private bool _isLoadingOrderItemFields;
+
+    public Dictionary<string, object?> Values { get; } = new();
+
+    public EditRecordForm(TableDefinition table, DataRowView? row)
+    {
+        _table = table;
+        _row = row;
+        _isEdit = row != null;
+
+        Text = (_isEdit ? "Изменение: " : "Добавление: ") + table.DisplayName;
+        StartPosition = FormStartPosition.CenterParent;
+        MinimumSize = new Size(560, 220);
+        Width = 620;
+        Height = Math.Min(760, 170 + table.Fields.Count * 56 + (table.TableName == "order_items" ? 56 : 0));
+        BackColor = AppTheme.Background;
+
+        BuildUi();
+        Load += EditRecordForm_Load;
+    }
+
+    private void BuildUi()
+    {
+        var root = new TableLayoutPanel
+        {
+            Dock = DockStyle.Fill,
+            RowCount = 2,
+            ColumnCount = 1,
+            Padding = new Padding(15),
+            BackColor = AppTheme.Background
+        };
+        root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
+        root.RowStyles.Add(new RowStyle(SizeType.Absolute, 55));
+        Controls.Add(root);
+
+        _layout.Dock = DockStyle.Fill;
+        _layout.AutoScroll = true;
+        _layout.BackColor = AppTheme.Background;
+        _layout.ColumnCount = 2;
+        _layout.RowCount = _table.Fields.Count + (_table.TableName == "order_items" ? 1 : 0);
+        _layout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 190));
+        _layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
+        root.Controls.Add(_layout, 0, 0);
+
+        int rowIndex = 0;
+        foreach (FieldDefinition field in _table.Fields)
+        {
+            var label = new Label
+            {
+                Text = field.Header + (field.Required && !(_isEdit && field.IsPassword) ? " *" : ""),
+                Dock = DockStyle.Fill,
+                TextAlign = ContentAlignment.MiddleLeft,
+                Font = AppTheme.Regular(10f)
+            };
+            _layout.Controls.Add(label, 0, rowIndex);
+
+            Control control = CreateControl(field);
+            control.Dock = DockStyle.Top;
+            control.Margin = new Padding(3, 8, 3, 3);
+            _controls[field.ColumnName] = control;
+            _layout.Controls.Add(control, 1, rowIndex);
+            _layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 56));
+            rowIndex++;
+        }
+
+        if (_table.TableName == "order_items")
+        {
+            var totalLabel = new Label
+            {
+                Text = "Сумма",
+                Dock = DockStyle.Fill,
+                TextAlign = ContentAlignment.MiddleLeft,
+                Font = AppTheme.Regular(10f)
+            };
+            _layout.Controls.Add(totalLabel, 0, rowIndex);
+
+            _orderItemTotalBox.ReadOnly = true;
+            _orderItemTotalBox.Font = AppTheme.Regular(10f);
+            _orderItemTotalBox.Dock = DockStyle.Top;
+            _orderItemTotalBox.Margin = new Padding(3, 8, 3, 3);
+            _layout.Controls.Add(_orderItemTotalBox, 1, rowIndex);
+            _layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 56));
+        }
+
+        var buttonPanel = new FlowLayoutPanel
+        {
+            Dock = DockStyle.Fill,
+            FlowDirection = FlowDirection.RightToLeft,
+            BackColor = AppTheme.Background
+        };
+        root.Controls.Add(buttonPanel, 0, 1);
+
+        _cancelButton.Text = "Отмена";
+        _cancelButton.Width = 120;
+        _cancelButton.Height = 38;
+        AppTheme.StyleSecondaryButton(_cancelButton);
+        _cancelButton.Click += (_, _) => DialogResult = DialogResult.Cancel;
+        buttonPanel.Controls.Add(_cancelButton);
+
+        _saveButton.Text = "Сохранить";
+        _saveButton.Width = 120;
+        _saveButton.Height = 38;
+        AppTheme.StylePrimaryButton(_saveButton);
+        _saveButton.Click += SaveButton_Click;
+        buttonPanel.Controls.Add(_saveButton);
+    }
+
+    private Control CreateControl(FieldDefinition field)
+    {
+        if (_table.TableName == "order_items" && field.ColumnName == "unit_price")
+        {
+            return new TextBox
+            {
+                ReadOnly = true,
+                Font = AppTheme.Regular(10f),
+                TextAlign = HorizontalAlignment.Left
+            };
+        }
+
+        switch (field.Kind)
+        {
+            case FieldKind.Integer:
+                return new NumericUpDown
+                {
+                    Minimum = field.MinValue ?? 0,
+                    Maximum = field.MaxValue ?? 100000000,
+                    DecimalPlaces = 0,
+                    ThousandsSeparator = true,
+                    Font = AppTheme.Regular(10f)
+                };
+
+            case FieldKind.Decimal:
+                return new NumericUpDown
+                {
+                    Minimum = field.MinValue ?? 0,
+                    Maximum = field.MaxValue ?? 100000000,
+                    DecimalPlaces = 2,
+                    ThousandsSeparator = true,
+                    Font = AppTheme.Regular(10f),
+                    Increment = 100
+                };
+
+            case FieldKind.Date:
+                var picker = new DateTimePicker
+                {
+                    Format = DateTimePickerFormat.Short,
+                    Font = AppTheme.Regular(10f)
+                };
+                if (field.ColumnName == "birth_date")
+                {
+                    picker.MaxDate = DateTime.Today;
+                }
+                return picker;
+
+            case FieldKind.Boolean:
+                return new CheckBox
+                {
+                    Text = "Да",
+                    Checked = true,
+                    Font = AppTheme.Regular(10f)
+                };
+
+            case FieldKind.Combo:
+                return new ComboBox
+                {
+                    DropDownStyle = ComboBoxStyle.DropDownList,
+                    Font = AppTheme.Regular(10f)
+                };
+
+            case FieldKind.Status:
+            case FieldKind.Role:
+                var combo = new ComboBox
+                {
+                    DropDownStyle = ComboBoxStyle.DropDownList,
+                    Font = AppTheme.Regular(10f)
+                };
+                combo.Items.AddRange(field.Items ?? Array.Empty<string>());
+                return combo;
+
+            case FieldKind.Password:
+                return new TextBox
+                {
+                    UseSystemPasswordChar = true,
+                    Font = AppTheme.Regular(10f),
+                    MaxLength = field.MaxLength > 0 ? field.MaxLength : 32767,
+                    PlaceholderText = _isEdit ? "Оставьте пустым, чтобы не менять пароль" : string.Empty
+                };
+
+            default:
+                return new TextBox
+                {
+                    Font = AppTheme.Regular(10f),
+                    MaxLength = field.MaxLength > 0 ? field.MaxLength : 32767,
+                    Multiline = field.ColumnName is "address" or "characteristics",
+                    Height = field.ColumnName is "address" or "characteristics" ? 70 : 26
+                };
+        }
+    }
+
+    private async void EditRecordForm_Load(object? sender, EventArgs e)
+    {
+        try
+        {
+            _isLoadingOrderItemFields = true;
+
+            foreach (FieldDefinition field in _table.Fields.Where(f => f.Kind == FieldKind.Combo))
+            {
+                if (_table.TableName == "order_items" && field.ColumnName == "product_id")
+                {
+                    continue;
+                }
+
+                var combo = (ComboBox)_controls[field.ColumnName];
+                combo.DataSource = await _repository.LoadComboItemsAsync(field);
+                combo.ValueMember = nameof(ComboItem.Id);
+                combo.DisplayMember = nameof(ComboItem.Text);
+            }
+
+            FillInitialValues();
+
+            if (_table.TableName == "order_items")
+            {
+                await ConfigureOrderItemFormAsync();
+            }
+
+            _isLoadingOrderItemFields = false;
+        }
+        catch (Exception ex)
+        {
+            ErrorHandler.Show("Ошибка загрузки формы", "Не удалось загрузить данные для формы.", ex);
+            DialogResult = DialogResult.Cancel;
+            Close();
+        }
+    }
+
+    private void FillInitialValues()
+    {
+        if (_row == null)
+        {
+            foreach (FieldDefinition field in _table.Fields)
+            {
+                if ((field.Kind == FieldKind.Status || field.Kind == FieldKind.Role) && _controls[field.ColumnName] is ComboBox combo && combo.Items.Count > 0)
+                {
+                    combo.SelectedIndex = 0;
+                }
+            }
+            return;
+        }
+
+        foreach (FieldDefinition field in _table.Fields)
+        {
+            if (field.IsPassword)
+            {
+                continue;
+            }
+
+            if (_table.TableName == "order_items" && field.ColumnName == "product_id")
+            {
+                continue;
+            }
+
+            object value = _row.Row.Table.Columns.Contains(field.ColumnName) ? _row[field.ColumnName] : DBNull.Value;
+            if (value == DBNull.Value)
+            {
+                SetEmptyValue(field);
+                continue;
+            }
+
+            Control control = _controls[field.ColumnName];
+            switch (control)
+            {
+                case TextBox textBox:
+                    textBox.Text = Convert.ToString(value) ?? string.Empty;
+                    break;
+                case NumericUpDown numeric:
+                    numeric.Value = Convert.ToDecimal(value);
+                    break;
+                case DateTimePicker picker:
+                    picker.Value = Convert.ToDateTime(value);
+                    break;
+                case CheckBox checkBox:
+                    checkBox.Checked = Convert.ToBoolean(value);
+                    break;
+                case ComboBox combo when field.Kind == FieldKind.Combo:
+                    combo.SelectedValue = Convert.ToInt32(value);
+                    break;
+                case ComboBox combo:
+                    combo.SelectedItem = Convert.ToString(value);
+                    break;
+            }
+        }
+    }
+
+    private async Task ConfigureOrderItemFormAsync()
+    {
+        if (!_controls.TryGetValue("order_id", out Control? orderControl) || orderControl is not ComboBox orderCombo)
+        {
+            return;
+        }
+
+        if (!_controls.TryGetValue("product_id", out Control? productControl) || productControl is not ComboBox productCombo)
+        {
+            return;
+        }
+
+        productCombo.ValueMember = nameof(ComboItem.Id);
+        productCombo.DisplayMember = nameof(ComboItem.Text);
+        productCombo.Enabled = orderCombo.SelectedValue != null;
+
+        orderCombo.SelectedValueChanged += async (_, _) =>
+        {
+            if (_isLoadingOrderItemFields)
+            {
+                return;
+            }
+
+            await LoadProductsForSelectedOrderAsync(null);
+        };
+
+        productCombo.SelectedValueChanged += async (_, _) =>
+        {
+            if (_isLoadingOrderItemFields)
+            {
+                return;
+            }
+
+            await SetPriceForSelectedProductAsync();
+        };
+
+        if (_controls.TryGetValue("quantity", out Control? quantityControl) && quantityControl is NumericUpDown quantityNumeric)
+        {
+            quantityNumeric.ValueChanged += (_, _) => UpdateOrderItemTotal();
+        }
+
+        int? selectedProductId = null;
+        if (_row != null && _row.Row.Table.Columns.Contains("product_id") && _row["product_id"] != DBNull.Value)
+        {
+            selectedProductId = Convert.ToInt32(_row["product_id"]);
+        }
+
+        await LoadProductsForSelectedOrderAsync(selectedProductId);
+
+        if (selectedProductId.HasValue)
+        {
+            productCombo.SelectedValue = selectedProductId.Value;
+        }
+
+        await SetPriceForSelectedProductAsync();
+    }
+
+    private async Task LoadProductsForSelectedOrderAsync(int? selectedProductId)
+    {
+        if (_controls["order_id"] is not ComboBox orderCombo || _controls["product_id"] is not ComboBox productCombo)
+        {
+            return;
+        }
+
+        if (orderCombo.SelectedValue == null)
+        {
+            productCombo.DataSource = new List<ComboItem>();
+            productCombo.Enabled = false;
+            return;
+        }
+
+        int orderId = Convert.ToInt32(orderCombo.SelectedValue);
+        List<ComboItem> products = await _repository.LoadProductsForOrderAsync(orderId, selectedProductId);
+
+        productCombo.DataSource = products;
+        productCombo.ValueMember = nameof(ComboItem.Id);
+        productCombo.DisplayMember = nameof(ComboItem.Text);
+        productCombo.Enabled = products.Count > 0;
+
+        if (products.Count == 0)
+        {
+            SetUnitPriceValue(0);
+
+            MessageBox.Show("На складе выбранного заказа нет товаров с положительным остатком.", "Проверка данных", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+        }
+    }
+
+    private async Task SetPriceForSelectedProductAsync()
+    {
+        if (_controls["product_id"] is not ComboBox productCombo)
+        {
+            return;
+        }
+
+        if (productCombo.SelectedValue == null)
+        {
+            SetUnitPriceValue(0);
+            return;
+        }
+
+        int productId = Convert.ToInt32(productCombo.SelectedValue);
+        decimal price = await _repository.LoadProductPriceAsync(productId);
+        SetUnitPriceValue(price);
+    }
+
+    private void SetUnitPriceValue(decimal price)
+    {
+        if (_controls.TryGetValue("unit_price", out Control? priceControl))
+        {
+            if (priceControl is TextBox priceTextBox)
+            {
+                priceTextBox.Text = price.ToString("N2", CultureInfo.CurrentCulture);
+            }
+            else if (priceControl is NumericUpDown priceNumeric)
+            {
+                priceNumeric.Value = Math.Min(priceNumeric.Maximum, Math.Max(priceNumeric.Minimum, price));
+            }
+        }
+
+        UpdateOrderItemTotal();
+    }
+
+    private void UpdateOrderItemTotal()
+    {
+        if (_table.TableName != "order_items")
+        {
+            return;
+        }
+
+        int quantity = 0;
+        decimal price = 0;
+
+        if (_controls.TryGetValue("quantity", out Control? quantityControl) && quantityControl is NumericUpDown quantityNumeric)
+        {
+            quantity = Convert.ToInt32(quantityNumeric.Value);
+        }
+
+        if (_controls.TryGetValue("unit_price", out Control? priceControl))
+        {
+            if (priceControl is TextBox priceTextBox)
+            {
+                decimal.TryParse(priceTextBox.Text, NumberStyles.Number, CultureInfo.CurrentCulture, out price);
+            }
+            else if (priceControl is NumericUpDown priceNumeric)
+            {
+                price = priceNumeric.Value;
+            }
+        }
+
+        _orderItemTotalBox.Text = (quantity * price).ToString("N2", CultureInfo.CurrentCulture);
+    }
+
+    private void SetEmptyValue(FieldDefinition field)
+    {
+        if (_controls[field.ColumnName] is ComboBox combo && field.Nullable)
+        {
+            combo.SelectedIndex = 0;
+        }
+        else if (_controls[field.ColumnName] is TextBox textBox)
+        {
+            textBox.Text = string.Empty;
+        }
+    }
+
+    private void SaveButton_Click(object? sender, EventArgs e)
+    {
+        if (!ValidateAndCollectValues())
+        {
+            return;
+        }
+
+        DialogResult = DialogResult.OK;
+        Close();
+    }
+
+    private bool ValidateAndCollectValues()
+    {
+        Values.Clear();
+
+        foreach (FieldDefinition field in _table.Fields)
+        {
+            Control control = _controls[field.ColumnName];
+            object? value;
+
+            switch (control)
+            {
+                case TextBox textBox:
+                    string text = textBox.Text.Trim();
+                    bool passwordMayBeEmpty = _isEdit && field.IsPassword;
+
+                    if (field.Required && !passwordMayBeEmpty && string.IsNullOrWhiteSpace(text))
+                    {
+                        WarnRequired(field);
+                        return false;
+                    }
+
+                    if (field.Kind == FieldKind.Decimal)
+                    {
+                        if (!decimal.TryParse(text, NumberStyles.Number, CultureInfo.CurrentCulture, out decimal decimalValue))
+                        {
+                            MessageBox.Show($"Поле \"{field.Header}\" должно быть числом.", "Проверка данных", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+                            return false;
+                        }
+
+                        if (field.MinValue.HasValue && decimalValue < field.MinValue.Value)
+                        {
+                            MessageBox.Show($"Поле \"{field.Header}\" не может быть меньше {field.MinValue.Value}.", "Проверка данных", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+                            return false;
+                        }
+
+                        value = decimalValue;
+                        break;
+                    }
+
+                    if (field.MaxLength > 0 && text.Length > field.MaxLength)
+                    {
+                        MessageBox.Show($"Поле \"{field.Header}\" не должно быть длиннее {field.MaxLength} символов.", "Проверка данных", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+                        return false;
+                    }
+
+                    value = string.IsNullOrWhiteSpace(text) && field.Nullable ? null : text;
+                    break;
+
+                case NumericUpDown numeric:
+                    value = field.Kind == FieldKind.Integer ? Convert.ToInt32(numeric.Value) : numeric.Value;
+                    if (field.MinValue.HasValue && Convert.ToDecimal(value) < field.MinValue.Value)
+                    {
+                        MessageBox.Show($"Поле \"{field.Header}\" не может быть меньше {field.MinValue.Value}.", "Проверка данных", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+                        return false;
+                    }
+                    break;
+
+                case DateTimePicker picker:
+                    if (field.ColumnName == "birth_date" && picker.Value.Date > DateTime.Today)
+                    {
+                        MessageBox.Show("Дата рождения не может быть в будущем.", "Проверка данных", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+                        return false;
+                    }
+                    value = picker.Value.Date;
+                    break;
+
+                case CheckBox checkBox:
+                    value = checkBox.Checked;
+                    break;
+
+                case ComboBox combo when field.Kind == FieldKind.Combo:
+                    if (field.Required && combo.SelectedValue == null)
+                    {
+                        WarnRequired(field);
+                        return false;
+                    }
+                    value = combo.SelectedValue;
+                    break;
+
+                case ComboBox combo:
+                    if (field.Required && combo.SelectedItem == null)
+                    {
+                        WarnRequired(field);
+                        return false;
+                    }
+                    value = Convert.ToString(combo.SelectedItem);
+                    break;
+
+                default:
+                    value = null;
+                    break;
+            }
+
+            Values[field.ColumnName] = value;
+        }
+
+        return true;
+    }
+
+    private static void WarnRequired(FieldDefinition field)
+    {
+        MessageBox.Show($"Заполните обязательное поле \"{field.Header}\".", "Проверка данных", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+    }
+}

+ 120 - 0
Computer_Sales_PM11/computer_sales/ComputerSalesApp/UI/EditRecordForm.resx

@@ -0,0 +1,120 @@
+<?xml version="1.0" encoding="utf-8"?>
+<root>
+  <!-- 
+    Microsoft ResX Schema 
+    
+    Version 2.0
+    
+    The primary goals of this format is to allow a simple XML format 
+    that is mostly human readable. The generation and parsing of the 
+    various data types are done through the TypeConverter classes 
+    associated with the data types.
+    
+    Example:
+    
+    ... ado.net/XML headers & schema ...
+    <resheader name="resmimetype">text/microsoft-resx</resheader>
+    <resheader name="version">2.0</resheader>
+    <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
+    <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
+    <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
+    <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
+    <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
+        <value>[base64 mime encoded serialized .NET Framework object]</value>
+    </data>
+    <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
+        <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
+        <comment>This is a comment</comment>
+    </data>
+                
+    There are any number of "resheader" rows that contain simple 
+    name/value pairs.
+    
+    Each data row contains a name, and value. The row also contains a 
+    type or mimetype. Type corresponds to a .NET class that support 
+    text/value conversion through the TypeConverter architecture. 
+    Classes that don't support this are serialized and stored with the 
+    mimetype set.
+    
+    The mimetype is used for serialized objects, and tells the 
+    ResXResourceReader how to depersist the object. This is currently not 
+    extensible. For a given mimetype the value must be set accordingly:
+    
+    Note - application/x-microsoft.net.object.binary.base64 is the format 
+    that the ResXResourceWriter will generate, however the reader can 
+    read any of the formats listed below.
+    
+    mimetype: application/x-microsoft.net.object.binary.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
+            : and then encoded with base64 encoding.
+    
+    mimetype: application/x-microsoft.net.object.soap.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
+            : and then encoded with base64 encoding.
+
+    mimetype: application/x-microsoft.net.object.bytearray.base64
+    value   : The object must be serialized into a byte array 
+            : using a System.ComponentModel.TypeConverter
+            : and then encoded with base64 encoding.
+    -->
+  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
+    <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
+    <xsd:element name="root" msdata:IsDataSet="true">
+      <xsd:complexType>
+        <xsd:choice maxOccurs="unbounded">
+          <xsd:element name="metadata">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" />
+              </xsd:sequence>
+              <xsd:attribute name="name" use="required" type="xsd:string" />
+              <xsd:attribute name="type" type="xsd:string" />
+              <xsd:attribute name="mimetype" type="xsd:string" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="assembly">
+            <xsd:complexType>
+              <xsd:attribute name="alias" type="xsd:string" />
+              <xsd:attribute name="name" type="xsd:string" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="data">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+                <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
+              <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
+              <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="resheader">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" />
+            </xsd:complexType>
+          </xsd:element>
+        </xsd:choice>
+      </xsd:complexType>
+    </xsd:element>
+  </xsd:schema>
+  <resheader name="resmimetype">
+    <value>text/microsoft-resx</value>
+  </resheader>
+  <resheader name="version">
+    <value>2.0</value>
+  </resheader>
+  <resheader name="reader">
+    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <resheader name="writer">
+    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+</root>

+ 189 - 0
Computer_Sales_PM11/computer_sales/ComputerSalesApp/UI/LoginForm.cs

@@ -0,0 +1,189 @@
+using ComputerSalesApp.Models;
+using ComputerSalesApp.Services;
+
+namespace ComputerSalesApp.UI;
+
+internal sealed class LoginForm : Form
+{
+    private readonly TextBox _loginTextBox = new();
+    private readonly TextBox _passwordTextBox = new();
+    private readonly Button _loginButton = new();
+    private readonly Button _exitButton = new();
+    private readonly AuthService _authService = new();
+
+    public UserSession? Session { get; private set; }
+
+    public LoginForm()
+    {
+        Text = "Авторизация | Продажа компьютерной техники";
+        StartPosition = FormStartPosition.CenterScreen;
+        MinimumSize = new Size(520, 430);
+        MaximumSize = new Size(520, 430);
+        BackColor = AppTheme.Background;
+        FormBorderStyle = FormBorderStyle.FixedSingle;
+        MaximizeBox = false;
+        BuildUi();
+    }
+
+    private void BuildUi()
+    {
+        var root = new TableLayoutPanel
+        {
+            Dock = DockStyle.Fill,
+            RowCount = 3,
+            ColumnCount = 1
+        };
+        root.RowStyles.Add(new RowStyle(SizeType.Absolute, 100));
+        root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
+        root.RowStyles.Add(new RowStyle(SizeType.Absolute, 80));
+        Controls.Add(root);
+
+        var header = new Panel
+        {
+            Dock = DockStyle.Fill,
+            BackColor = AppTheme.Header,
+            Padding = new Padding(35, 20, 20, 10)
+        };
+        root.Controls.Add(header, 0, 0);
+
+        var title = new Label
+        {
+            Text = "Вход в систему",
+            ForeColor = Color.White,
+            Font = AppTheme.Bold(18f),
+            AutoSize = true,
+            Location = new Point(35, 20)
+        };
+        header.Controls.Add(title);
+
+        var subtitle = new Label
+        {
+            Text = "Продажа компьютерной техники",
+            ForeColor = Color.WhiteSmoke,
+            Font = AppTheme.Regular(10f),
+            AutoSize = true,
+            Location = new Point(38, 60)
+        };
+        header.Controls.Add(subtitle);
+
+        var formPanel = new TableLayoutPanel
+        {
+            Dock = DockStyle.Fill,
+            Padding = new Padding(45, 30, 45, 10),
+            BackColor = AppTheme.Background,
+            RowCount = 4,
+            ColumnCount = 1
+        };
+        formPanel.RowStyles.Add(new RowStyle(SizeType.Absolute, 32));
+        formPanel.RowStyles.Add(new RowStyle(SizeType.Absolute, 50));
+        formPanel.RowStyles.Add(new RowStyle(SizeType.Absolute, 32));
+        formPanel.RowStyles.Add(new RowStyle(SizeType.Absolute, 50));
+        root.Controls.Add(formPanel, 0, 1);
+
+        formPanel.Controls.Add(new Label
+        {
+            Text = "Логин:",
+            Dock = DockStyle.Fill,
+            TextAlign = ContentAlignment.BottomLeft,
+            Font = AppTheme.Regular(10f)
+        }, 0, 0);
+
+        _loginTextBox.Dock = DockStyle.Top;
+        _loginTextBox.Font = AppTheme.Regular(10f);
+        _loginTextBox.Margin = new Padding(0, 5, 0, 0);
+        formPanel.Controls.Add(_loginTextBox, 0, 1);
+
+        formPanel.Controls.Add(new Label
+        {
+            Text = "Пароль:",
+            Dock = DockStyle.Fill,
+            TextAlign = ContentAlignment.BottomLeft,
+            Font = AppTheme.Regular(10f)
+        }, 0, 2);
+
+        _passwordTextBox.Dock = DockStyle.Top;
+        _passwordTextBox.Font = AppTheme.Regular(10f);
+        _passwordTextBox.UseSystemPasswordChar = true;
+        _passwordTextBox.Margin = new Padding(0, 5, 0, 0);
+        formPanel.Controls.Add(_passwordTextBox, 0, 3);
+
+        var buttonPanel = new FlowLayoutPanel
+        {
+            Dock = DockStyle.Fill,
+            FlowDirection = FlowDirection.RightToLeft,
+            Padding = new Padding(45, 15, 45, 10),
+            BackColor = AppTheme.Background
+        };
+        root.Controls.Add(buttonPanel, 0, 2);
+
+        _exitButton.Text = "Выход";
+        _exitButton.Width = 130;
+        _exitButton.Height = 42;
+        _exitButton.Font = AppTheme.Bold(10f);
+        AppTheme.StyleSecondaryButton(_exitButton);
+        _exitButton.Click += (_, _) => Close();
+        buttonPanel.Controls.Add(_exitButton);
+
+        _loginButton.Text = "Войти";
+        _loginButton.Width = 130;
+        _loginButton.Height = 42;
+        _loginButton.Font = AppTheme.Bold(10f);
+        AppTheme.StylePrimaryButton(_loginButton);
+        _loginButton.Click += LoginButton_Click;
+        buttonPanel.Controls.Add(_loginButton);
+
+        AcceptButton = _loginButton;
+        CancelButton = _exitButton;
+
+        _loginTextBox.Text = "admin";
+        _passwordTextBox.Text = "admin123";
+    }
+
+    private async void LoginButton_Click(object? sender, EventArgs e)
+    {
+        string login = _loginTextBox.Text.Trim();
+        string password = _passwordTextBox.Text;
+
+        if (string.IsNullOrWhiteSpace(login) || string.IsNullOrWhiteSpace(password))
+        {
+            MessageBox.Show("Введите логин и пароль.", "Авторизация", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+            return;
+        }
+
+        SetBusy(true);
+        try
+        {
+            Session = await _authService.AuthenticateAsync(login, password);
+            if (Session == null)
+            {
+                MessageBox.Show(
+                    "Пользователь не найден, отключён или пароль введён неверно.",
+                    "Ошибка авторизации",
+                    MessageBoxButtons.OK,
+                    MessageBoxIcon.Warning);
+                return;
+            }
+
+            DialogResult = DialogResult.OK;
+            Close();
+        }
+        catch (Exception ex)
+        {
+            ErrorHandler.Show("Ошибка авторизации", "Не удалось выполнить вход.", ex);
+        }
+        finally
+        {
+            SetBusy(false);
+        }
+    }
+
+    private void SetBusy(bool busy)
+    {
+        _loginButton.Enabled = !busy;
+        _exitButton.Enabled = !busy;
+        _loginTextBox.Enabled = !busy;
+        _passwordTextBox.Enabled = !busy;
+        _loginButton.Text = busy ? "Проверка..." : "Войти";
+        Cursor = busy ? Cursors.WaitCursor : Cursors.Default;
+    }
+}

+ 120 - 0
Computer_Sales_PM11/computer_sales/ComputerSalesApp/UI/LoginForm.resx

@@ -0,0 +1,120 @@
+<?xml version="1.0" encoding="utf-8"?>
+<root>
+  <!-- 
+    Microsoft ResX Schema 
+    
+    Version 2.0
+    
+    The primary goals of this format is to allow a simple XML format 
+    that is mostly human readable. The generation and parsing of the 
+    various data types are done through the TypeConverter classes 
+    associated with the data types.
+    
+    Example:
+    
+    ... ado.net/XML headers & schema ...
+    <resheader name="resmimetype">text/microsoft-resx</resheader>
+    <resheader name="version">2.0</resheader>
+    <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
+    <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
+    <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
+    <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
+    <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
+        <value>[base64 mime encoded serialized .NET Framework object]</value>
+    </data>
+    <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
+        <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
+        <comment>This is a comment</comment>
+    </data>
+                
+    There are any number of "resheader" rows that contain simple 
+    name/value pairs.
+    
+    Each data row contains a name, and value. The row also contains a 
+    type or mimetype. Type corresponds to a .NET class that support 
+    text/value conversion through the TypeConverter architecture. 
+    Classes that don't support this are serialized and stored with the 
+    mimetype set.
+    
+    The mimetype is used for serialized objects, and tells the 
+    ResXResourceReader how to depersist the object. This is currently not 
+    extensible. For a given mimetype the value must be set accordingly:
+    
+    Note - application/x-microsoft.net.object.binary.base64 is the format 
+    that the ResXResourceWriter will generate, however the reader can 
+    read any of the formats listed below.
+    
+    mimetype: application/x-microsoft.net.object.binary.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
+            : and then encoded with base64 encoding.
+    
+    mimetype: application/x-microsoft.net.object.soap.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
+            : and then encoded with base64 encoding.
+
+    mimetype: application/x-microsoft.net.object.bytearray.base64
+    value   : The object must be serialized into a byte array 
+            : using a System.ComponentModel.TypeConverter
+            : and then encoded with base64 encoding.
+    -->
+  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
+    <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
+    <xsd:element name="root" msdata:IsDataSet="true">
+      <xsd:complexType>
+        <xsd:choice maxOccurs="unbounded">
+          <xsd:element name="metadata">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" />
+              </xsd:sequence>
+              <xsd:attribute name="name" use="required" type="xsd:string" />
+              <xsd:attribute name="type" type="xsd:string" />
+              <xsd:attribute name="mimetype" type="xsd:string" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="assembly">
+            <xsd:complexType>
+              <xsd:attribute name="alias" type="xsd:string" />
+              <xsd:attribute name="name" type="xsd:string" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="data">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+                <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
+              <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
+              <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="resheader">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" />
+            </xsd:complexType>
+          </xsd:element>
+        </xsd:choice>
+      </xsd:complexType>
+    </xsd:element>
+  </xsd:schema>
+  <resheader name="resmimetype">
+    <value>text/microsoft-resx</value>
+  </resheader>
+  <resheader name="version">
+    <value>2.0</value>
+  </resheader>
+  <resheader name="reader">
+    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <resheader name="writer">
+    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+</root>

+ 751 - 0
Computer_Sales_PM11/computer_sales/ComputerSalesApp/UI/MainForm.cs

@@ -0,0 +1,751 @@
+using System.Data;
+using System.Text;
+using ComputerSalesApp.Data;
+using ComputerSalesApp.Models;
+using ComputerSalesApp.Services;
+
+namespace ComputerSalesApp.UI;
+
+internal sealed class MainForm : Form
+{
+    private readonly UserSession _session;
+    private readonly TableRepository _repository = new();
+    private readonly List<TableDefinition> _tables;
+
+    private readonly ListBox _tableList = new();
+    private readonly DataGridView _dataGrid = new();
+    private readonly Button _refreshButton = new();
+    private readonly Button _addButton = new();
+    private readonly Button _editButton = new();
+    private readonly Button _deleteButton = new();
+    private readonly Button _logoutButton = new();
+    private readonly Label _currentTableLabel = new();
+    private readonly Label _userLabel = new();
+
+    private readonly ComboBox _reportCustomerCombo = new();
+    private readonly DateTimePicker _reportDatePicker = new();
+    private readonly ComboBox _reportSortCombo = new();
+    private readonly Button _reportFindButton = new();
+    private readonly Button _reportExportButton = new();
+    private readonly DataGridView _reportGrid = new();
+    private readonly Label _reportHintLabel = new();
+
+    private TableDefinition? CurrentTable => _tableList.SelectedItem as TableDefinition;
+
+    public bool LogoutRequested { get; private set; }
+
+    public MainForm(UserSession session)
+    {
+        _session = session;
+        _tables = TableCatalog.CreateTables(session)
+            .Where(t => !t.AdminOnly || session.IsAdmin)
+            .ToList();
+
+        Text = "Продажа компьютерной техники";
+        StartPosition = FormStartPosition.CenterScreen;
+        MinimumSize = new Size(1120, 730);
+        BackColor = AppTheme.Background;
+        BuildUi();
+        Load += MainForm_Load;
+    }
+
+    private void BuildUi()
+    {
+        var root = new TableLayoutPanel
+        {
+            Dock = DockStyle.Fill,
+            RowCount = 2,
+            ColumnCount = 1,
+            BackColor = AppTheme.Background
+        };
+        root.RowStyles.Add(new RowStyle(SizeType.Absolute, 82));
+        root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
+        Controls.Add(root);
+
+        var header = new Panel
+        {
+            Dock = DockStyle.Fill,
+            BackColor = AppTheme.Header,
+            Padding = new Padding(24, 12, 18, 8)
+        };
+        root.Controls.Add(header, 0, 0);
+
+        var title = new Label
+        {
+            Text = "Продажа компьютерной техники",
+            ForeColor = Color.White,
+            Font = AppTheme.Bold(17f),
+            AutoSize = true,
+            Location = new Point(24, 14)
+        };
+        header.Controls.Add(title);
+
+        string roleText = _session.IsAdmin ? "администратор" : "покупатель";
+        _userLabel.Text = $"Пользователь: {_session.Login} | Роль: {roleText}";
+        _userLabel.ForeColor = Color.WhiteSmoke;
+        _userLabel.Font = AppTheme.Regular(10f);
+        _userLabel.AutoSize = true;
+        _userLabel.Location = new Point(26, 48);
+        header.Controls.Add(_userLabel);
+
+        _logoutButton.Text = "Выйти";
+        _logoutButton.Width = 120;
+        _logoutButton.Height = 36;
+        _logoutButton.Anchor = AnchorStyles.Top | AnchorStyles.Right;
+        _logoutButton.Location = new Point(header.Width - _logoutButton.Width - 18, 23);
+        AppTheme.StyleSecondaryButton(_logoutButton);
+        _logoutButton.Click += (_, _) =>
+        {
+            LogoutRequested = true;
+            Close();
+        };
+        header.Controls.Add(_logoutButton);
+        header.Resize += (_, _) => _logoutButton.Location = new Point(header.Width - _logoutButton.Width - 18, 23);
+
+        var tabs = new TabControl
+        {
+            Dock = DockStyle.Fill,
+            Font = AppTheme.Regular(10f)
+        };
+        root.Controls.Add(tabs, 0, 1);
+
+        var dataTab = new TabPage("Данные") { BackColor = AppTheme.Background };
+        var reportTab = new TabPage("Заказы покупателя по дате") { BackColor = AppTheme.Background };
+        tabs.TabPages.Add(dataTab);
+        tabs.TabPages.Add(reportTab);
+
+        BuildDataTab(dataTab);
+        BuildReportTab(reportTab);
+    }
+
+    private void BuildDataTab(TabPage tab)
+    {
+        var layout = new TableLayoutPanel
+        {
+            Dock = DockStyle.Fill,
+            RowCount = 1,
+            ColumnCount = 2,
+            BackColor = AppTheme.Background,
+            Padding = new Padding(0)
+        };
+        layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
+        layout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 290));
+        layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
+        tab.Controls.Add(layout);
+
+        var sidebar = new Panel
+        {
+            Dock = DockStyle.Fill,
+            BackColor = AppTheme.Surface,
+            Padding = new Padding(18, 16, 18, 14),
+            MinimumSize = new Size(290, 0)
+        };
+        layout.Controls.Add(sidebar, 0, 0);
+
+        var left = new TableLayoutPanel
+        {
+            Dock = DockStyle.Fill,
+            RowCount = 2,
+            ColumnCount = 1,
+            Padding = new Padding(0),
+            BackColor = AppTheme.Surface
+        };
+        left.RowStyles.Add(new RowStyle(SizeType.Absolute, 42));
+        left.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
+        sidebar.Controls.Add(left);
+
+        left.Controls.Add(new Label
+        {
+            Text = "Разделы",
+            Dock = DockStyle.Fill,
+            Font = AppTheme.Bold(12.5f),
+            ForeColor = AppTheme.Text,
+            TextAlign = ContentAlignment.MiddleLeft
+        }, 0, 0);
+
+        _tableList.Dock = DockStyle.Fill;
+        _tableList.Font = AppTheme.Regular(10.5f);
+        _tableList.ItemHeight = 24;
+        _tableList.BorderStyle = BorderStyle.FixedSingle;
+        _tableList.BackColor = Color.White;
+        _tableList.ForeColor = AppTheme.Text;
+        _tableList.HorizontalScrollbar = false;
+        _tableList.IntegralHeight = false;
+        _tableList.SelectedIndexChanged += async (_, _) => await LoadCurrentTableAsync();
+        left.Controls.Add(_tableList, 0, 1);
+
+        var right = new TableLayoutPanel
+        {
+            Dock = DockStyle.Fill,
+            RowCount = 3,
+            ColumnCount = 1,
+            Padding = new Padding(18, 16, 18, 14),
+            BackColor = AppTheme.Background
+        };
+        right.RowStyles.Add(new RowStyle(SizeType.Absolute, 44));
+        right.RowStyles.Add(new RowStyle(SizeType.Absolute, 52));
+        right.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
+        layout.Controls.Add(right, 1, 0);
+
+        _currentTableLabel.Text = "Выберите раздел";
+        _currentTableLabel.Dock = DockStyle.Fill;
+        _currentTableLabel.Font = AppTheme.Bold(14f);
+        _currentTableLabel.ForeColor = AppTheme.Text;
+        _currentTableLabel.TextAlign = ContentAlignment.MiddleLeft;
+        right.Controls.Add(_currentTableLabel, 0, 0);
+
+        var buttons = new FlowLayoutPanel
+        {
+            Dock = DockStyle.Fill,
+            FlowDirection = FlowDirection.LeftToRight,
+            BackColor = AppTheme.Background,
+            Padding = new Padding(0, 2, 0, 2)
+        };
+        right.Controls.Add(buttons, 0, 1);
+
+        ConfigureButton(_refreshButton, "Обновить", async (_, _) => await LoadCurrentTableAsync(), primary: true);
+        ConfigureButton(_addButton, "Добавить", async (_, _) => await AddRecordAsync());
+        ConfigureButton(_editButton, "Изменить", async (_, _) => await EditRecordAsync());
+        ConfigureButton(_deleteButton, "Удалить", async (_, _) => await DeleteRecordAsync());
+
+        buttons.Controls.AddRange(new Control[] { _refreshButton, _addButton, _editButton, _deleteButton });
+
+        if (!_session.IsAdmin)
+        {
+            _addButton.Visible = false;
+            _editButton.Visible = false;
+            _deleteButton.Visible = false;
+        }
+
+        _dataGrid.Dock = DockStyle.Fill;
+        _dataGrid.ReadOnly = true;
+        _dataGrid.AllowUserToAddRows = false;
+        _dataGrid.AllowUserToDeleteRows = false;
+        _dataGrid.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
+        _dataGrid.MultiSelect = false;
+        AppTheme.StyleGrid(_dataGrid);
+        right.Controls.Add(_dataGrid, 0, 2);
+    }
+
+    private void BuildReportTab(TabPage tab)
+    {
+        var root = new TableLayoutPanel
+        {
+            Dock = DockStyle.Fill,
+            RowCount = 3,
+            ColumnCount = 1,
+            Padding = new Padding(18),
+            BackColor = AppTheme.Background
+        };
+        root.RowStyles.Add(new RowStyle(SizeType.Absolute, 24));
+        root.RowStyles.Add(new RowStyle(SizeType.Absolute, 88));
+        root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
+        tab.Controls.Add(root);
+
+        _reportHintLabel.Dock = DockStyle.Fill;
+        _reportHintLabel.ForeColor = AppTheme.Text;
+        _reportHintLabel.Font = AppTheme.Regular(9.5f);
+        _reportHintLabel.Text = _session.IsAdmin
+            ? "Администратор видит заказы любого покупателя."
+            : "Покупатель видит только свои заказы.";
+        root.Controls.Add(_reportHintLabel, 0, 0);
+
+        var filters = new TableLayoutPanel
+        {
+            Dock = DockStyle.Fill,
+            RowCount = 2,
+            ColumnCount = 5,
+            BackColor = AppTheme.Background
+        };
+        filters.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 40));
+        filters.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 180));
+        filters.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 270));
+        filters.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 160));
+        filters.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 180));
+        root.Controls.Add(filters, 0, 1);
+
+        AddFilterLabel(filters, "Покупатель", 0);
+        AddFilterLabel(filters, "Дата заказа", 1);
+        AddFilterLabel(filters, "Сортировка", 2);
+
+        _reportCustomerCombo.DropDownStyle = ComboBoxStyle.DropDownList;
+        _reportCustomerCombo.Dock = DockStyle.Top;
+        _reportCustomerCombo.Font = AppTheme.Regular(10f);
+        _reportCustomerCombo.Enabled = _session.IsAdmin;
+        filters.Controls.Add(_reportCustomerCombo, 0, 1);
+
+        _reportDatePicker.Format = DateTimePickerFormat.Short;
+        _reportDatePicker.Dock = DockStyle.Top;
+        _reportDatePicker.Font = AppTheme.Regular(10f);
+        filters.Controls.Add(_reportDatePicker, 1, 1);
+
+        _reportSortCombo.DropDownStyle = ComboBoxStyle.DropDownList;
+        _reportSortCombo.Items.AddRange(new object[] { "По возрастанию количества", "По убыванию количества" });
+        _reportSortCombo.SelectedIndex = 0;
+        _reportSortCombo.Dock = DockStyle.Top;
+        _reportSortCombo.Font = AppTheme.Regular(10f);
+        filters.Controls.Add(_reportSortCombo, 2, 1);
+
+        _reportFindButton.Text = "Найти";
+        _reportFindButton.Width = 140;
+        _reportFindButton.Height = 32;
+        _reportFindButton.Margin = new Padding(8, 0, 0, 0);
+        AppTheme.StylePrimaryButton(_reportFindButton);
+        _reportFindButton.Click += async (_, _) => await LoadReportAsync();
+        filters.Controls.Add(_reportFindButton, 3, 1);
+
+        _reportExportButton.Text = "Сохранить отчёт";
+        _reportExportButton.Width = 160;
+        _reportExportButton.Height = 32;
+        _reportExportButton.Margin = new Padding(8, 0, 0, 0);
+        _reportExportButton.Enabled = false;
+        AppTheme.StyleSecondaryButton(_reportExportButton);
+        _reportExportButton.Click += (_, _) => ExportReport();
+        filters.Controls.Add(_reportExportButton, 4, 1);
+
+        _reportGrid.Dock = DockStyle.Fill;
+        _reportGrid.ReadOnly = true;
+        _reportGrid.AllowUserToAddRows = false;
+        _reportGrid.AllowUserToDeleteRows = false;
+        _reportGrid.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
+        _reportGrid.MultiSelect = false;
+        AppTheme.StyleGrid(_reportGrid);
+        root.Controls.Add(_reportGrid, 0, 2);
+    }
+
+    private static void AddFilterLabel(TableLayoutPanel filters, string text, int column)
+    {
+        filters.Controls.Add(new Label
+        {
+            Text = text,
+            Dock = DockStyle.Fill,
+            TextAlign = ContentAlignment.BottomLeft,
+            ForeColor = AppTheme.Text,
+            Font = AppTheme.Regular(10f)
+        }, column, 0);
+    }
+
+    private static void ConfigureButton(Button button, string text, EventHandler onClick, bool primary = false)
+    {
+        button.Text = text;
+        button.Width = 135;
+        button.Height = 36;
+        button.Margin = new Padding(0, 5, 10, 5);
+        if (primary)
+        {
+            AppTheme.StylePrimaryButton(button);
+        }
+        else
+        {
+            AppTheme.StyleSecondaryButton(button);
+        }
+        button.Click += onClick;
+    }
+
+    private async void MainForm_Load(object? sender, EventArgs e)
+    {
+        try
+        {
+            _tableList.DataSource = _tables;
+            if (_tables.Count > 0)
+            {
+                _tableList.SelectedIndex = 0;
+            }
+
+            await LoadReportCustomersAsync();
+        }
+        catch (Exception ex)
+        {
+            ErrorHandler.Show("Ошибка запуска", "Не удалось загрузить начальные данные приложения.", ex);
+        }
+    }
+
+    private async Task LoadCurrentTableAsync()
+    {
+        TableDefinition? table = CurrentTable;
+        if (table == null)
+        {
+            return;
+        }
+
+        SetBusy(true);
+        try
+        {
+            _currentTableLabel.Text = table.DisplayName;
+            DataTable data = await _repository.LoadTableAsync(table);
+            _dataGrid.DataSource = data;
+            ApplyGridSettings(_dataGrid, table.Headers, table.HiddenColumns);
+            UpdateActionButtons();
+        }
+        catch (Exception ex)
+        {
+            ErrorHandler.Show("Ошибка загрузки", $"Не удалось загрузить раздел \"{table.DisplayName}\".", ex);
+        }
+        finally
+        {
+            SetBusy(false);
+        }
+    }
+
+    private async Task AddRecordAsync()
+    {
+        if (!EnsureAdmin()) return;
+        TableDefinition? table = CurrentTable;
+        if (table == null || table.ReadOnly) return;
+
+        using var form = new EditRecordForm(table, null);
+        if (form.ShowDialog(this) != DialogResult.OK)
+        {
+            return;
+        }
+
+        SetBusy(true);
+        try
+        {
+            await _repository.InsertAsync(table, form.Values);
+            await LoadCurrentTableAsync();
+        }
+        catch (Exception ex)
+        {
+            ErrorHandler.Show("Ошибка добавления", $"Не удалось добавить запись в раздел \"{table.DisplayName}\".", ex);
+        }
+        finally
+        {
+            SetBusy(false);
+        }
+    }
+
+    private async Task EditRecordAsync()
+    {
+        if (!EnsureAdmin()) return;
+        TableDefinition? table = CurrentTable;
+        if (table == null || table.ReadOnly) return;
+        DataRowView? row = GetSelectedRow();
+        if (table == null || row == null) return;
+
+        using var form = new EditRecordForm(table, row);
+        if (form.ShowDialog(this) != DialogResult.OK)
+        {
+            return;
+        }
+
+        SetBusy(true);
+        try
+        {
+            int id = Convert.ToInt32(row[table.IdColumn]);
+            await _repository.UpdateAsync(table, id, form.Values);
+            await LoadCurrentTableAsync();
+        }
+        catch (Exception ex)
+        {
+            ErrorHandler.Show("Ошибка изменения", $"Не удалось изменить запись в разделе \"{table.DisplayName}\".", ex);
+        }
+        finally
+        {
+            SetBusy(false);
+        }
+    }
+
+    private async Task DeleteRecordAsync()
+    {
+        if (!EnsureAdmin()) return;
+        TableDefinition? table = CurrentTable;
+        if (table == null || table.ReadOnly) return;
+        DataRowView? row = GetSelectedRow();
+        if (table == null || row == null) return;
+
+        if (!ErrorHandler.Confirm("Удалить выбранную запись? Если она связана с другими таблицами, база данных не позволит удалить её."))
+        {
+            return;
+        }
+
+        SetBusy(true);
+        try
+        {
+            int id = Convert.ToInt32(row[table.IdColumn]);
+            await _repository.DeleteAsync(table, id);
+            await LoadCurrentTableAsync();
+        }
+        catch (Exception ex)
+        {
+            ErrorHandler.Show("Ошибка удаления", $"Не удалось удалить запись из раздела \"{table.DisplayName}\".", ex);
+        }
+        finally
+        {
+            SetBusy(false);
+        }
+    }
+
+    private async Task LoadReportCustomersAsync()
+    {
+        DataTable customers = await _repository.LoadCustomersForReportAsync(_session);
+        var items = customers.Rows.Cast<DataRow>()
+            .Where(row => row.Table.Columns.Contains("id") && row["id"] != DBNull.Value)
+            .Select(row => new ComboItem
+            {
+                Id = Convert.ToInt32(row["id"]),
+                Text = Convert.ToString(row["text"]) ?? string.Empty
+            })
+            .ToList();
+
+        _reportCustomerCombo.ValueMember = nameof(ComboItem.Id);
+        _reportCustomerCombo.DisplayMember = nameof(ComboItem.Text);
+        _reportCustomerCombo.DataSource = items;
+
+        if (items.Count == 0)
+        {
+            _reportHintLabel.Text = _session.IsAdmin
+                ? "В таблице покупателей нет записей. Добавьте покупателя, чтобы искать заказы."
+                : "Для этого пользователя не указан покупатель. Проверьте поле customer_id в app_users.";
+            _reportGrid.DataSource = new DataTable();
+            return;
+        }
+
+        _reportCustomerCombo.SelectedIndex = 0;
+        await SetLatestReportDateAsync();
+
+        // Отчёт не запускается автоматически при старте, чтобы случайная ошибка
+        // в представлении или пустая дата не мешала работать с основными таблицами.
+        // Пользователь сам нажимает кнопку "Найти".
+    }
+
+    private async Task SetLatestReportDateAsync()
+    {
+        int? customerId = GetSelectedReportCustomerId(showMessage: false);
+        if (!customerId.HasValue)
+        {
+            return;
+        }
+
+        DateTime? latest = await _repository.LoadLatestOrderDateAsync(customerId.Value);
+        if (latest.HasValue)
+        {
+            _reportDatePicker.Value = latest.Value;
+        }
+    }
+
+    private int? GetSelectedReportCustomerId(bool showMessage)
+    {
+        if (_reportCustomerCombo.SelectedItem is ComboItem selectedItem && selectedItem.Id.HasValue)
+        {
+            return selectedItem.Id.Value;
+        }
+
+        if (_reportCustomerCombo.SelectedValue is int selectedValue)
+        {
+            return selectedValue;
+        }
+
+        if (_reportCustomerCombo.SelectedValue != null &&
+            int.TryParse(Convert.ToString(_reportCustomerCombo.SelectedValue), out int parsedValue))
+        {
+            return parsedValue;
+        }
+
+        if (showMessage)
+        {
+            MessageBox.Show("Выберите покупателя.", "Поиск заказов", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+        }
+
+        return null;
+    }
+
+    private async Task LoadReportAsync()
+    {
+        int? customerId = GetSelectedReportCustomerId(showMessage: true);
+        if (!customerId.HasValue)
+        {
+            return;
+        }
+
+        SetBusy(true);
+        try
+        {
+            bool descending = _reportSortCombo.SelectedIndex == 1;
+            DataTable data = await _repository.LoadCustomerOrderItemsAsync(_session, customerId.Value, _reportDatePicker.Value.Date, descending);
+            _reportGrid.DataSource = data;
+            _reportExportButton.Enabled = data.Rows.Count > 0;
+
+            ApplyGridSettings(_reportGrid, new Dictionary<string, string>
+            {
+                ["order_id"] = "№ заказа",
+                ["order_date"] = "Дата заказа",
+                ["customer_full_name"] = "Покупатель",
+                ["employee_full_name"] = "Сотрудник",
+                ["product_name"] = "Товар",
+                ["manufacturer"] = "Изготовитель",
+                ["quantity"] = "Количество",
+                ["unit_price"] = "Цена за единицу",
+                ["item_total"] = "Сумма",
+                ["status"] = "Статус"
+            }, new HashSet<string>());
+
+            if (data.Rows.Count == 0)
+            {
+                MessageBox.Show("За выбранную дату заказов не найдено.", "Поиск заказов", MessageBoxButtons.OK, MessageBoxIcon.Information);
+            }
+        }
+        catch (Exception ex)
+        {
+            ErrorHandler.Show("Ошибка поиска", "Не удалось получить сведения о заказах покупателя за указанную дату.", ex);
+        }
+        finally
+        {
+            SetBusy(false);
+        }
+    }
+
+    private void ExportReport()
+    {
+        if (_reportGrid.DataSource is not DataTable data || data.Rows.Count == 0)
+        {
+            MessageBox.Show("Сначала сформируйте отчёт.", "Сохранение отчёта", MessageBoxButtons.OK, MessageBoxIcon.Information);
+            return;
+        }
+
+        using var dialog = new SaveFileDialog
+        {
+            Title = "Сохранить отчёт",
+            Filter = "CSV-файл (*.csv)|*.csv",
+            FileName = $"orders_report_{DateTime.Now:yyyyMMdd_HHmm}.csv"
+        };
+
+        if (dialog.ShowDialog(this) != DialogResult.OK)
+        {
+            return;
+        }
+
+        try
+        {
+            var visibleColumns = _reportGrid.Columns
+                .Cast<DataGridViewColumn>()
+                .Where(c => c.Visible)
+                .OrderBy(c => c.DisplayIndex)
+                .ToList();
+
+            var lines = new List<string>();
+            lines.Add(string.Join(";", visibleColumns.Select(c => EscapeCsv(c.HeaderText))));
+
+            foreach (DataRow row in data.Rows)
+            {
+                var values = visibleColumns.Select(c =>
+                {
+                    object value = row.Table.Columns.Contains(c.Name) ? row[c.Name] : string.Empty;
+                    return EscapeCsv(value == DBNull.Value ? string.Empty : Convert.ToString(value) ?? string.Empty);
+                });
+                lines.Add(string.Join(";", values));
+            }
+
+            File.WriteAllLines(dialog.FileName, lines, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
+            MessageBox.Show("Отчёт сохранён.", "Сохранение отчёта", MessageBoxButtons.OK, MessageBoxIcon.Information);
+        }
+        catch (Exception ex)
+        {
+            ErrorHandler.Show("Ошибка сохранения отчёта", "Не удалось сохранить отчётный CSV-файл.", ex);
+        }
+    }
+
+    private static string EscapeCsv(string value)
+    {
+        string escaped = value.Replace("\"", "\"\"");
+        return $"\"{escaped}\"";
+    }
+
+    private DataRowView? GetSelectedRow()
+    {
+        if (_dataGrid.CurrentRow?.DataBoundItem is DataRowView row)
+        {
+            return row;
+        }
+
+        MessageBox.Show("Выберите запись в таблице.", "Выбор записи", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+        return null;
+    }
+
+    private bool EnsureAdmin()
+    {
+        if (_session.IsAdmin)
+        {
+            return true;
+        }
+
+        MessageBox.Show("Изменять данные может только администратор.", "Ограничение доступа", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+        return false;
+    }
+
+    private static void ApplyGridSettings(DataGridView grid, Dictionary<string, string>? headers, HashSet<string>? hiddenColumns)
+    {
+        if (grid == null || grid.Columns.Count == 0)
+        {
+            return;
+        }
+
+        headers ??= new Dictionary<string, string>();
+        hiddenColumns ??= new HashSet<string>();
+
+        grid.AutoGenerateColumns = true;
+        grid.AllowUserToAddRows = false;
+        grid.AllowUserToDeleteRows = false;
+        grid.ReadOnly = true;
+        grid.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
+        grid.MultiSelect = false;
+        AppTheme.StyleGrid(grid);
+
+        foreach (DataGridViewColumn column in grid.Columns)
+        {
+            if (column == null)
+            {
+                continue;
+            }
+
+            string columnName = column.Name ?? string.Empty;
+            if (headers.TryGetValue(columnName, out string? header) && !string.IsNullOrWhiteSpace(header))
+            {
+                column.HeaderText = header;
+            }
+
+            bool hide = hiddenColumns.Contains(columnName);
+            column.Visible = !hide;
+
+            if (!hide)
+            {
+                string headerText = string.IsNullOrWhiteSpace(column.HeaderText) ? columnName : column.HeaderText;
+                column.MinimumWidth = Math.Max(95, headerText.Length * 8);
+            }
+        }
+    }
+
+    private void UpdateActionButtons()
+    {
+        if (!_session.IsAdmin)
+        {
+            return;
+        }
+
+        bool canEdit = CurrentTable is { ReadOnly: false };
+        _addButton.Visible = canEdit;
+        _editButton.Visible = canEdit;
+        _deleteButton.Visible = canEdit;
+    }
+
+    private void SetBusy(bool busy)
+    {
+        Cursor = busy ? Cursors.WaitCursor : Cursors.Default;
+        _refreshButton.Enabled = !busy;
+        _tableList.Enabled = !busy;
+        _reportFindButton.Enabled = !busy;
+        _reportExportButton.Enabled = !busy && _reportGrid.DataSource is DataTable reportData && reportData.Rows.Count > 0;
+        _reportCustomerCombo.Enabled = !busy && _session.IsAdmin && _reportCustomerCombo.Items.Count > 0;
+        _reportDatePicker.Enabled = !busy;
+        _reportSortCombo.Enabled = !busy;
+        _logoutButton.Enabled = !busy;
+
+        if (_session.IsAdmin)
+        {
+            bool canEdit = !busy && CurrentTable is { ReadOnly: false };
+            _addButton.Enabled = canEdit;
+            _editButton.Enabled = canEdit;
+            _deleteButton.Enabled = canEdit;
+        }
+    }
+}

+ 120 - 0
Computer_Sales_PM11/computer_sales/ComputerSalesApp/UI/MainForm.resx

@@ -0,0 +1,120 @@
+<?xml version="1.0" encoding="utf-8"?>
+<root>
+  <!-- 
+    Microsoft ResX Schema 
+    
+    Version 2.0
+    
+    The primary goals of this format is to allow a simple XML format 
+    that is mostly human readable. The generation and parsing of the 
+    various data types are done through the TypeConverter classes 
+    associated with the data types.
+    
+    Example:
+    
+    ... ado.net/XML headers & schema ...
+    <resheader name="resmimetype">text/microsoft-resx</resheader>
+    <resheader name="version">2.0</resheader>
+    <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
+    <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
+    <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
+    <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
+    <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
+        <value>[base64 mime encoded serialized .NET Framework object]</value>
+    </data>
+    <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
+        <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
+        <comment>This is a comment</comment>
+    </data>
+                
+    There are any number of "resheader" rows that contain simple 
+    name/value pairs.
+    
+    Each data row contains a name, and value. The row also contains a 
+    type or mimetype. Type corresponds to a .NET class that support 
+    text/value conversion through the TypeConverter architecture. 
+    Classes that don't support this are serialized and stored with the 
+    mimetype set.
+    
+    The mimetype is used for serialized objects, and tells the 
+    ResXResourceReader how to depersist the object. This is currently not 
+    extensible. For a given mimetype the value must be set accordingly:
+    
+    Note - application/x-microsoft.net.object.binary.base64 is the format 
+    that the ResXResourceWriter will generate, however the reader can 
+    read any of the formats listed below.
+    
+    mimetype: application/x-microsoft.net.object.binary.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
+            : and then encoded with base64 encoding.
+    
+    mimetype: application/x-microsoft.net.object.soap.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
+            : and then encoded with base64 encoding.
+
+    mimetype: application/x-microsoft.net.object.bytearray.base64
+    value   : The object must be serialized into a byte array 
+            : using a System.ComponentModel.TypeConverter
+            : and then encoded with base64 encoding.
+    -->
+  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
+    <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
+    <xsd:element name="root" msdata:IsDataSet="true">
+      <xsd:complexType>
+        <xsd:choice maxOccurs="unbounded">
+          <xsd:element name="metadata">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" />
+              </xsd:sequence>
+              <xsd:attribute name="name" use="required" type="xsd:string" />
+              <xsd:attribute name="type" type="xsd:string" />
+              <xsd:attribute name="mimetype" type="xsd:string" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="assembly">
+            <xsd:complexType>
+              <xsd:attribute name="alias" type="xsd:string" />
+              <xsd:attribute name="name" type="xsd:string" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="data">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+                <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
+              <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
+              <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="resheader">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" />
+            </xsd:complexType>
+          </xsd:element>
+        </xsd:choice>
+      </xsd:complexType>
+    </xsd:element>
+  </xsd:schema>
+  <resheader name="resmimetype">
+    <value>text/microsoft-resx</value>
+  </resheader>
+  <resheader name="version">
+    <value>2.0</value>
+  </resheader>
+  <resheader name="reader">
+    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <resheader name="writer">
+    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+</root>

+ 70 - 0
Computer_Sales_PM11/computer_sales/README.md

@@ -0,0 +1,70 @@
+# ComputerSalesApp
+
+WinForms-приложение для темы «Продажа компьютерной техники».
+
+## Подключение
+
+Строка подключения находится в файле:
+
+`ComputerSalesApp/Data/ConnectionSettings.cs`
+
+Используется схема `ryzhov_ka`.
+
+## Роли
+
+- `admin` — видит все таблицы и может добавлять, изменять, удалять записи.
+- `user` — покупатель. Видит только свои данные, товары, свои заказы и позиции своих заказов.
+
+## Тестовые пользователи
+
+- `admin / admin123`
+- `user / user123`
+
+## Важное изменение логики пользователей
+
+Учётная запись `user` теперь связана с покупателем через поле `app_users.customer_id`, а не с сотрудником. Это нужно для ограничения доступа: покупатель не должен видеть чужие заказы.
+
+## Порядок подготовки БД
+
+Для новой базы:
+
+1. `database/sql/01_schema.sql`
+2. импорт CSV по `database/csv/import_order.txt`
+3. `database/sql/03_pgcrypto_auth.sql`
+4. `database/sql/04_views.sql`
+5. `database/sql/05_after_import_identity.sql`
+
+Для уже созданной старой базы можно выполнить:
+
+`database/sql/06_patch_app_users_customer.sql`
+
+## Исправление 2026-06-01
+
+- Исправлена ошибка `Object reference not set to an instance of an object` при поиске заказов.
+- Отчёт больше не запускается автоматически при открытии приложения: пользователь сам нажимает `Найти`.
+- Настройка колонок DataGridView стала безопасной: отсутствующие или пустые колонки больше не вызывают падение.
+- Левое меню расширено, чтобы названия разделов меньше обрезались.
+
+
+## Исправление тестовых покупателей
+
+В тестовых данных покупатели и сотрудники теперь разные люди. Если база уже была создана раньше, выполните в pgAdmin:
+
+```sql
+-- database/sql/07_fix_test_customer_names.sql
+```
+
+Скрипт меняет только ФИО и контактные данные покупателей, структуру БД не трогает.
+
+
+## Проверенная последовательность подготовки БД
+
+1. Выполнить `database/sql/01_schema.sql`.
+2. Импортировать CSV из `database/csv` по порядку из `import_order.txt`.
+3. Выполнить `database/sql/03_pgcrypto_auth.sql`.
+4. Выполнить `database/sql/04_views.sql`.
+5. Выполнить `database/sql/05_after_import_identity.sql`.
+6. Выполнить `database/sql/08_security_audit.sql`.
+
+Если база уже создана раньше, без полного пересоздания можно выполнить:
+`06_patch_app_users_customer.sql`, затем `04_views.sql`, затем `08_security_audit.sql`.

+ 105 - 0
README.md

@@ -0,0 +1,105 @@
+# Информационная система «Продажа компьютерной техники»
+
+# Аннотация
+
+«Продажа компьютерной техники» — клиентское Windows-приложение для учёта товаров, складов, покупателей, сотрудников и заказов магазина компьютерной техники. Программа работает с отдельно развёрнутой базой данных PostgreSQL и предоставляет разные возможности администратору и покупателю.
+
+В репозитории размещается исходный код клиентской части информационной системы. База данных, SQL-скрипты и резервные копии в установочный пакет приложения не входят.
+
+# Введение
+
+Основные возможности программы:
+
+- авторизация по логину и паролю;
+- разграничение доступа по ролям `admin` и `user`;
+- просмотр сведений о складах, сотрудниках, покупателях и товарах;
+- учёт остатков товаров на складах;
+- создание и обработка заказов;
+- просмотр позиций, входящих в заказ;
+- поиск и фильтрация записей;
+- ведение журнала аудита изменений;
+- формирование отчёта по заказам покупателя за выбранную дату;
+- просмотр покупателем собственных данных и заказов.
+
+# Назначение и условия применения
+
+Программа предназначена для автоматизации учёта продаж компьютерной техники, комплектующих и периферийных устройств. Она может применяться сотрудниками магазина или организации, в которой требуется вести сведения о товарах, покупателях, заказах и складских остатках.
+
+Для работы необходимы:
+
+- компьютер под управлением Windows;
+- доступ к заранее развёрнутой базе данных PostgreSQL;
+- корректно настроенные параметры подключения к базе данных;
+- учётная запись пользователя программы;
+- .NET 8 Desktop Runtime, если приложение опубликовано без включённой среды выполнения;
+- права на запуск приложения и запись формируемых файлов в выбранную папку.
+
+Клиентское приложение не создаёт сервер PostgreSQL автоматически. Перед первым запуском база данных должна быть развёрнута и доступна по сети.
+
+# Установка
+
+1. [ ] Получить установочный файл `ComputerSalesSetup.exe`.
+2. [ ] Убедиться, что сервер PostgreSQL запущен и база данных информационной системы доступна.
+3. [ ] При необходимости установить .NET 8 Desktop Runtime.
+4. [ ] Запустить `ComputerSalesSetup.exe`.
+5. [ ] Выбрать папку установки или оставить предложенный путь.
+6. [ ] Подтвердить создание ярлыков в меню «Пуск» и на рабочем столе.
+7. [ ] Дождаться завершения копирования файлов.
+8. [ ] Запустить программу через созданный ярлык.
+9. [ ] Ввести выданные администратором логин и пароль.
+10. [ ] Убедиться, что после входа загружаются доступные пользователю данные.
+
+Удаление приложения выполняется через стандартный раздел Windows для удаления программ либо с помощью файла деинсталляции, созданного Inno Setup.
+
+# Описание операций
+
+## Авторизация
+
+После запуска необходимо ввести логин и пароль. Состав доступных разделов определяется ролью учётной записи.
+
+## Работа администратора
+
+Администратор может:
+
+- просматривать основные таблицы информационной системы;
+- добавлять, изменять и удалять записи;
+- управлять сведениями о складах, сотрудниках, покупателях и пользователях;
+- вести справочник товаров;
+- контролировать остатки товаров на складах;
+- работать с заказами и позициями заказов;
+- выполнять поиск и фильтрацию;
+- просматривать журнал аудита;
+- формировать отчёт по заказам выбранного покупателя за указанную дату.
+
+Перед удалением записи необходимо убедиться, что она не используется связанными данными. Если база данных отклоняет операцию, сначала следует проверить зависимые записи.
+
+## Работа покупателя
+
+Пользователь с ролью `user` может:
+
+- просматривать личные данные;
+- просматривать список товаров;
+- открывать собственные заказы;
+- просматривать позиции собственных заказов;
+- использовать доступные средства поиска и просмотра.
+
+Покупатель не может изменять справочники, сведения других пользователей и журнал аудита.
+
+# Термины и сокращения
+
+| Термин | Определение |
+|---|---|
+| БД | База данных, в которой хранятся сведения информационной системы |
+| СУБД | Система управления базами данных |
+| PostgreSQL | СУБД, используемая серверной частью информационной системы |
+| Клиентское приложение | Программа, установленная на компьютере пользователя и подключающаяся к БД |
+| Товар | Компьютерная техника, комплектующее или периферийное устройство |
+| Склад | Место хранения товаров |
+| Остаток | Количество определённого товара, доступное на складе |
+| Покупатель | Клиент, сведения о котором используются при оформлении заказа |
+| Заказ | Запись о покупке одного или нескольких товаров |
+| Позиция заказа | Конкретный товар и его количество внутри заказа |
+| Администратор | Пользователь с правом просмотра и изменения данных |
+| Пользователь | Покупатель с ограниченным доступом к системе |
+| Аудит | Журнал, содержащий сведения об изменениях данных |
+| Inno Setup | Средство создания установочного файла Windows |

+ 204 - 0
Руководство оператора.md

@@ -0,0 +1,204 @@
+# Руководство оператора программы «Продажа компьютерной техники»
+
+# Аннотация
+
+Настоящее руководство предназначено для сотрудников и покупателей, работающих с информационной системой «Продажа компьютерной техники». В документе приведены требования к подготовке рабочего места, порядок проверки установленной программы, правила выполнения основных операций и действия при возникновении ошибок.
+
+Руководство описывает работу с клиентским приложением. Развёртывание и администрирование сервера PostgreSQL выполняются ответственным специалистом отдельно.
+
+# Введение
+
+## Область применения
+
+Программа используется для ведения учёта компьютерной техники, складских остатков, покупателей и заказов. Администратор обслуживает данные магазина, а покупатель получает доступ только к сведениям, относящимся к его учётной записи и заказам.
+
+## Основные возможности
+
+Оператору доступны следующие функции в зависимости от его роли:
+
+- вход в систему по логину и паролю;
+- просмотр данных в табличном виде;
+- поиск и фильтрация записей;
+- ведение справочников и заказов администратором;
+- просмотр журнала аудита;
+- формирование отчёта по заказам;
+- просмотр покупателем личных данных, товаров и собственных заказов.
+
+# Подготовка к работе
+
+## Порядок установки
+
+1. Закройте работающие экземпляры программы.
+2. Запустите файл `ComputerSalesSetup.exe`.
+3. Разрешите запуск установщика, если Windows выведет системное предупреждение.
+4. Выберите папку установки. При отсутствии специальных требований оставьте путь по умолчанию.
+5. Выберите создание ярлыка на рабочем столе и в меню «Пуск».
+6. Нажмите кнопку начала установки и дождитесь завершения копирования файлов.
+7. Завершите работу мастера установки.
+8. Запустите приложение через созданный ярлык.
+
+Если приложение требует отдельно установленную среду выполнения, предварительно установите .NET 8 Desktop Runtime. Параметры подключения к PostgreSQL должны быть подготовлены ответственным специалистом.
+
+## Порядок проверки работоспособности
+
+После установки выполните контрольную проверку:
+
+1. Запустите программу.
+2. Убедитесь, что открылась форма авторизации.
+3. Введите действующие учётные данные.
+4. Проверьте, что главное окно открылось без сообщения об ошибке подключения.
+5. Откройте любой доступный раздел и убедитесь, что таблица заполнилась данными.
+6. Выполните поиск по известному значению и очистите фильтр.
+7. Для администратора создайте тестовую запись в разрешённом разделе, измените её и удалите.
+8. Откройте журнал аудита и проверьте фиксацию выполненных изменений.
+9. Сформируйте тестовый отчёт по покупателю и дате.
+10. Для покупателя проверьте открытие личных данных, списка товаров и собственных заказов.
+
+Если все перечисленные действия выполняются без ошибок, программа готова к эксплуатации.
+
+# Описание операций
+
+## 1. Вход в систему
+
+1. Запустите приложение.
+2. Введите логин в поле учётной записи.
+3. Введите пароль.
+4. Нажмите кнопку входа.
+5. Дождитесь загрузки главного окна.
+
+При повторяющейся ошибке авторизации не вводите пароль многократно. Проверьте раскладку клавиатуры и обратитесь к администратору учётных записей.
+
+## 2. Просмотр данных
+
+После входа откройте нужный раздел через главное окно. Записи отображаются в таблице. Для просмотра скрытых столбцов используйте горизонтальную прокрутку, а для просмотра большого списка — вертикальную.
+
+Права пользователя определяют, какие разделы можно открыть и какие действия разрешены.
+
+## 3. Поиск и фильтрация
+
+1. Откройте раздел, в котором требуется найти запись.
+2. Введите значение в поле поиска или выберите доступный фильтр.
+3. Проверьте полученный список.
+4. Для возврата ко всем данным очистите поле поиска или сбросьте фильтр.
+
+При отсутствии результатов проверьте правильность написания, числовой формат и выбранный раздел.
+
+## 4. Добавление записи администратором
+
+1. Откройте требуемый раздел.
+2. Нажмите кнопку добавления.
+3. Заполните обязательные поля.
+4. Для связанных данных выберите существующие значения из доступных списков.
+5. Проверьте количество, цену, дату и другие числовые значения.
+6. Сохраните запись.
+7. Убедитесь, что новая строка появилась в таблице.
+
+Не создавайте несколько одинаковых записей, если значение должно быть уникальным.
+
+## 5. Изменение записи администратором
+
+1. Выберите запись в таблице.
+2. Откройте режим изменения.
+3. Исправьте необходимые значения.
+4. Сохраните изменения.
+5. Обновите таблицу и проверьте результат.
+
+Перед изменением заказа или складского остатка убедитесь, что новое значение соответствует фактическим данным.
+
+## 6. Удаление записи администратором
+
+1. Выберите удаляемую запись.
+2. Нажмите кнопку удаления.
+3. Проверьте, что выбрана нужная строка.
+4. Подтвердите действие.
+5. Убедитесь, что запись исчезла из таблицы.
+
+Удаление может быть запрещено базой данных, если запись используется в заказах, остатках или других связанных таблицах.
+
+## 7. Работа с товарами и остатками
+
+Администратор открывает раздел товаров для ведения номенклатуры. Сведения о количестве товара на конкретном складе учитываются в разделе остатков.
+
+При изменении остатка необходимо:
+
+1. выбрать нужный склад;
+2. выбрать товар;
+3. указать фактическое количество;
+4. сохранить запись;
+5. проверить отображение нового значения.
+
+## 8. Работа с заказами
+
+Администратор может просматривать заказы и связанные с ними позиции. При обработке заказа следует проверить покупателя, дату и состав заказа.
+
+Позиция заказа должна ссылаться на существующий товар и соответствующий заказ. Количество товара указывается положительным числом.
+
+## 9. Формирование отчёта
+
+1. Откройте раздел формирования отчёта.
+2. Выберите покупателя.
+3. Укажите дату заказа.
+4. Запустите формирование.
+5. Проверьте полученные данные.
+6. Выберите место сохранения, если программа выводит диалог сохранения.
+7. Убедитесь, что итоговый файл создан и открывается.
+
+Пустой отчёт обычно означает, что по выбранным параметрам отсутствуют заказы.
+
+## 10. Просмотр журнала аудита
+
+Журнал аудита доступен администратору. В нём отображаются сведения об изменениях, выполненных в системе. Используйте журнал для проверки факта добавления, изменения или удаления записей.
+
+Не изменяйте записи аудита вручную, если такая возможность предусмотрена средствами администрирования базы данных.
+
+## 11. Работа покупателя
+
+После входа под обычной учётной записью пользователь может:
+
+1. открыть личные сведения и проверить их актуальность;
+2. просмотреть доступные товары;
+3. открыть список своих заказов;
+4. выбрать заказ и просмотреть его позиции;
+5. использовать доступный поиск.
+
+Данные других покупателей и административные разделы обычному пользователю недоступны.
+
+## 12. Завершение работы
+
+Закройте открытые формы и завершите приложение штатной кнопкой закрытия окна. Не выключайте компьютер во время сохранения записи или формирования отчёта.
+
+# Аварийные ситуации
+
+| Ситуация | Возможная причина | Действия оператора |
+|---|---|---|
+| Программа не запускается | Повреждены или удалены файлы приложения; отсутствует требуемая среда выполнения | Перезапустите компьютер, проверьте наличие файлов в папке установки, установите требуемый .NET Runtime или переустановите программу |
+| Появляется ошибка подключения к БД | Сервер PostgreSQL недоступен; отсутствует сеть; неверны параметры подключения | Проверьте сетевое подключение, повторите запуск и сообщите ответственному специалисту |
+| Данные не загружаются | В БД нет записей или запрос завершился ошибкой | Обновите раздел, откройте другую таблицу, затем обратитесь к администратору |
+| Не принимается логин или пароль | Ошибка ввода; неверная раскладка; учётная запись отключена | Проверьте раскладку и регистр символов, повторите ввод один раз, затем обратитесь к администратору |
+| Запись не сохраняется | Не заполнено обязательное поле; неверный формат даты или числа; нарушена связь между таблицами | Проверьте все поля, выберите существующие связанные записи и повторите сохранение |
+| Запись не удаляется | На неё ссылаются другие данные | Не пытайтесь удалять связанные данные произвольно; обратитесь к администратору БД |
+| Остаток или сумма отображаются неверно | Введено ошибочное количество, цена или состав заказа | Сверьте исходные данные и исправьте запись с правами администратора |
+| Отчёт пустой | Для выбранного покупателя и даты нет заказов | Измените параметры и повторите формирование |
+| Файл отчёта не сохраняется | Нет прав на запись или файл уже открыт другой программой | Выберите доступную папку, закройте ранее открытый файл и повторите операцию |
+| Программа перестала отвечать | Длительный запрос, потеря соединения или системная ошибка | Подождите завершения текущей операции, затем закройте приложение средствами Windows и сообщите администратору |
+
+При обнаружении повреждения данных прекратите операции изменения и сообщите ответственному специалисту. Не редактируйте базу данных вручную.
+
+# Термины и сокращения
+
+| Термин | Определение |
+|---|---|
+| Оператор | Пользователь, выполняющий действия в программе |
+| Клиентское приложение | Установленная на рабочем компьютере программа |
+| БД | База данных |
+| PostgreSQL | Серверная СУБД, в которой хранятся данные |
+| Авторизация | Проверка логина и пароля пользователя |
+| Роль | Набор разрешённых пользователю операций |
+| Администратор | Оператор с правом управления основными данными |
+| Покупатель | Пользователь с доступом к собственным сведениям и заказам |
+| Номенклатура | Перечень товаров, учитываемых в системе |
+| Складской остаток | Учтённое количество товара на определённом складе |
+| Заказ | Оформленная покупка товаров |
+| Позиция заказа | Строка заказа с конкретным товаром и количеством |
+| Аудит | Регистрация действий, изменяющих данные |
+| Отчёт | Сформированный программой документ с выбранными данными |