TheLim148 před 2 týdny
revize
9306b95eaf
24 změnil soubory, kde provedl 4041 přidání a 0 odebrání
  1. 27 0
      .gitignore
  2. binární
      AutoschoolSetup.exe
  3. 45 0
      AutoschoolSetup.iss
  4. 25 0
      Autoschool_PM11/autoschool/WindowsFormsApp6.sln
  5. 39 0
      Autoschool_PM11/autoschool/WindowsFormsApp6/App.config
  6. 216 0
      Autoschool_PM11/autoschool/WindowsFormsApp6/LoginForm.Designer.cs
  7. 268 0
      Autoschool_PM11/autoschool/WindowsFormsApp6/LoginForm.cs
  8. 120 0
      Autoschool_PM11/autoschool/WindowsFormsApp6/LoginForm.resx
  9. 531 0
      Autoschool_PM11/autoschool/WindowsFormsApp6/MainForm.Designer.cs
  10. 799 0
      Autoschool_PM11/autoschool/WindowsFormsApp6/MainForm.cs
  11. 120 0
      Autoschool_PM11/autoschool/WindowsFormsApp6/MainForm.resx
  12. 16 0
      Autoschool_PM11/autoschool/WindowsFormsApp6/Program.cs
  13. 36 0
      Autoschool_PM11/autoschool/WindowsFormsApp6/Properties/AssemblyInfo.cs
  14. 71 0
      Autoschool_PM11/autoschool/WindowsFormsApp6/Properties/Resources.Designer.cs
  15. 117 0
      Autoschool_PM11/autoschool/WindowsFormsApp6/Properties/Resources.resx
  16. 30 0
      Autoschool_PM11/autoschool/WindowsFormsApp6/Properties/Settings.Designer.cs
  17. 7 0
      Autoschool_PM11/autoschool/WindowsFormsApp6/Properties/Settings.settings
  18. 124 0
      Autoschool_PM11/autoschool/WindowsFormsApp6/RecordEditForm.Designer.cs
  19. 775 0
      Autoschool_PM11/autoschool/WindowsFormsApp6/RecordEditForm.cs
  20. 120 0
      Autoschool_PM11/autoschool/WindowsFormsApp6/RecordEditForm.resx
  21. 177 0
      Autoschool_PM11/autoschool/WindowsFormsApp6/WindowsFormsApp6.csproj
  22. 23 0
      Autoschool_PM11/autoschool/WindowsFormsApp6/packages.config
  23. 122 0
      README.md
  24. 233 0
      Руководство оператора.md

+ 27 - 0
.gitignore

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

binární
AutoschoolSetup.exe


+ 45 - 0
AutoschoolSetup.iss

@@ -0,0 +1,45 @@
+; 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 "WindowsFormsApp6.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={{796F3EF3-3AEA-4A7F-ACC5-6A12355592F3}
+AppName={#MyAppName}
+AppVersion={#MyAppVersion}
+;AppVerName={#MyAppName} {#MyAppVersion}
+AppPublisher={#MyAppPublisher}
+DefaultDirName={autopf}\{#MyAppName}
+UninstallDisplayIcon={app}\{#MyAppExeName}
+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=AutoschoolSetup
+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\ЗАЩИТА\ДРАКОША\autoschool\WindowsFormsApp6\bin\Release\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
+Source: "C:\Users\Admin\Desktop\Учёба\2025-2026\ЗАЩИТА\ДРАКОША\autoschool\WindowsFormsApp6\bin\Release\*"; 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
+

+ 25 - 0
Autoschool_PM11/autoschool/WindowsFormsApp6.sln

@@ -0,0 +1,25 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.8.34330.188
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WindowsFormsApp6", "WindowsFormsApp6\WindowsFormsApp6.csproj", "{A3E8A077-88C9-48F0-A591-AF6AD5FC350C}"
+EndProject
+Global
+	GlobalSection(SolutionConfigurationPlatforms) = preSolution
+		Debug|Any CPU = Debug|Any CPU
+		Release|Any CPU = Release|Any CPU
+	EndGlobalSection
+	GlobalSection(ProjectConfigurationPlatforms) = postSolution
+		{A3E8A077-88C9-48F0-A591-AF6AD5FC350C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{A3E8A077-88C9-48F0-A591-AF6AD5FC350C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{A3E8A077-88C9-48F0-A591-AF6AD5FC350C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{A3E8A077-88C9-48F0-A591-AF6AD5FC350C}.Release|Any CPU.Build.0 = Release|Any CPU
+	EndGlobalSection
+	GlobalSection(SolutionProperties) = preSolution
+		HideSolutionNode = FALSE
+	EndGlobalSection
+	GlobalSection(ExtensibilityGlobals) = postSolution
+		SolutionGuid = {2109F53C-9C52-432F-8AA7-BC07F48FA1DB}
+	EndGlobalSection
+EndGlobal

+ 39 - 0
Autoschool_PM11/autoschool/WindowsFormsApp6/App.config

@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8"?>
+<configuration>
+	<connectionStrings>
+		<add name="AutoschoolDb"
+			 connectionString="Host=edu-pg.itiscaf.ru;Port=5432;Database=dbwork;Username=akulova_vs;Password=&amp;U#%6&amp;kEr$%;Search Path=akulova_vs,public"
+			 providerName="Npgsql" />
+	</connectionStrings>
+  <startup>
+    <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
+  </startup>
+  <runtime>
+    <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
+      <dependentAssembly>
+        <assemblyIdentity name="System.Numerics.Vectors" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
+        <bindingRedirect oldVersion="0.0.0.0-4.1.4.0" newVersion="4.1.4.0" />
+      </dependentAssembly>
+      <dependentAssembly>
+        <assemblyIdentity name="System.Runtime.CompilerServices.Unsafe" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
+        <bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0" />
+      </dependentAssembly>
+      <dependentAssembly>
+        <assemblyIdentity name="System.Buffers" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
+        <bindingRedirect oldVersion="0.0.0.0-4.0.3.0" newVersion="4.0.3.0" />
+      </dependentAssembly>
+      <dependentAssembly>
+        <assemblyIdentity name="System.Threading.Tasks.Extensions" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
+        <bindingRedirect oldVersion="0.0.0.0-4.2.0.1" newVersion="4.2.0.1" />
+      </dependentAssembly>
+      <dependentAssembly>
+        <assemblyIdentity name="System.ValueTuple" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
+        <bindingRedirect oldVersion="0.0.0.0-4.0.3.0" newVersion="4.0.3.0" />
+      </dependentAssembly>
+      <dependentAssembly>
+        <assemblyIdentity name="System.Memory" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
+        <bindingRedirect oldVersion="0.0.0.0-4.0.1.2" newVersion="4.0.1.2" />
+      </dependentAssembly>
+    </assemblyBinding>
+  </runtime>
+</configuration>

+ 216 - 0
Autoschool_PM11/autoschool/WindowsFormsApp6/LoginForm.Designer.cs

@@ -0,0 +1,216 @@
+namespace SimpleDBViewer
+{
+    partial class LoginForm
+    {
+        private System.ComponentModel.IContainer components = null;
+        private System.Windows.Forms.Panel pnlSidebar;
+        private System.Windows.Forms.Label lblLogo;
+        private System.Windows.Forms.Label lblInfo;
+        private System.Windows.Forms.Panel pnlContent;
+        private System.Windows.Forms.Label lblTitle;
+        private System.Windows.Forms.Label lblLogin;
+        private System.Windows.Forms.TextBox txtLogin;
+        private System.Windows.Forms.Label lblPassword;
+        private System.Windows.Forms.TextBox txtPassword;
+        private System.Windows.Forms.Label lblError;
+        private System.Windows.Forms.Button btnLogin;
+        private System.Windows.Forms.Button btnExit;
+
+        protected override void Dispose(bool disposing)
+        {
+            if (disposing && (components != null))
+            {
+                components.Dispose();
+            }
+            base.Dispose(disposing);
+        }
+
+        private void InitializeComponent()
+        {
+            this.pnlSidebar = new System.Windows.Forms.Panel();
+            this.lblInfo = new System.Windows.Forms.Label();
+            this.lblLogo = new System.Windows.Forms.Label();
+            this.pnlContent = new System.Windows.Forms.Panel();
+            this.btnExit = new System.Windows.Forms.Button();
+            this.btnLogin = new System.Windows.Forms.Button();
+            this.lblError = new System.Windows.Forms.Label();
+            this.txtPassword = new System.Windows.Forms.TextBox();
+            this.lblPassword = new System.Windows.Forms.Label();
+            this.txtLogin = new System.Windows.Forms.TextBox();
+            this.lblLogin = new System.Windows.Forms.Label();
+            this.lblTitle = new System.Windows.Forms.Label();
+            this.pnlSidebar.SuspendLayout();
+            this.pnlContent.SuspendLayout();
+            this.SuspendLayout();
+            // 
+            // pnlSidebar
+            // 
+            this.pnlSidebar.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(30)))), ((int)(((byte)(58)))), ((int)(((byte)(84)))));
+            this.pnlSidebar.Controls.Add(this.lblInfo);
+            this.pnlSidebar.Controls.Add(this.lblLogo);
+            this.pnlSidebar.Dock = System.Windows.Forms.DockStyle.Left;
+            this.pnlSidebar.Location = new System.Drawing.Point(0, 0);
+            this.pnlSidebar.Name = "pnlSidebar";
+            this.pnlSidebar.Size = new System.Drawing.Size(260, 420);
+            this.pnlSidebar.TabIndex = 0;
+            // 
+            // lblInfo
+            // 
+            this.lblInfo.Dock = System.Windows.Forms.DockStyle.Top;
+            this.lblInfo.Font = new System.Drawing.Font("Segoe UI", 10.5F);
+            this.lblInfo.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(226)))), ((int)(((byte)(232)))), ((int)(((byte)(240)))));
+            this.lblInfo.Location = new System.Drawing.Point(0, 140);
+            this.lblInfo.Name = "lblInfo";
+            this.lblInfo.Padding = new System.Windows.Forms.Padding(28, 15, 28, 0);
+            this.lblInfo.Size = new System.Drawing.Size(260, 110);
+            this.lblInfo.TabIndex = 1;
+            this.lblInfo.Text = "Учёт автошкол, водителей, экзаменов и удостоверений";
+            this.lblInfo.TextAlign = System.Drawing.ContentAlignment.TopCenter;
+            this.lblInfo.Click += new System.EventHandler(this.lblInfo_Click);
+            // 
+            // lblLogo
+            // 
+            this.lblLogo.Dock = System.Windows.Forms.DockStyle.Top;
+            this.lblLogo.Font = new System.Drawing.Font("Segoe UI", 21F, System.Drawing.FontStyle.Bold);
+            this.lblLogo.ForeColor = System.Drawing.Color.White;
+            this.lblLogo.Location = new System.Drawing.Point(0, 0);
+            this.lblLogo.Name = "lblLogo";
+            this.lblLogo.Size = new System.Drawing.Size(260, 140);
+            this.lblLogo.TabIndex = 0;
+            this.lblLogo.Text = "АВТОШКОЛА";
+            this.lblLogo.TextAlign = System.Drawing.ContentAlignment.MiddleCenter;
+            // 
+            // pnlContent
+            // 
+            this.pnlContent.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(248)))), ((int)(((byte)(250)))), ((int)(((byte)(252)))));
+            this.pnlContent.Controls.Add(this.btnExit);
+            this.pnlContent.Controls.Add(this.btnLogin);
+            this.pnlContent.Controls.Add(this.lblError);
+            this.pnlContent.Controls.Add(this.txtPassword);
+            this.pnlContent.Controls.Add(this.lblPassword);
+            this.pnlContent.Controls.Add(this.txtLogin);
+            this.pnlContent.Controls.Add(this.lblLogin);
+            this.pnlContent.Controls.Add(this.lblTitle);
+            this.pnlContent.Dock = System.Windows.Forms.DockStyle.Fill;
+            this.pnlContent.Location = new System.Drawing.Point(260, 0);
+            this.pnlContent.Name = "pnlContent";
+            this.pnlContent.Padding = new System.Windows.Forms.Padding(60, 55, 60, 45);
+            this.pnlContent.Size = new System.Drawing.Size(500, 420);
+            this.pnlContent.TabIndex = 1;
+            // 
+            // btnExit
+            // 
+            this.btnExit.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(226)))), ((int)(((byte)(232)))), ((int)(((byte)(240)))));
+            this.btnExit.DialogResult = System.Windows.Forms.DialogResult.Cancel;
+            this.btnExit.FlatAppearance.BorderSize = 0;
+            this.btnExit.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
+            this.btnExit.Font = new System.Drawing.Font("Segoe UI", 10F, System.Drawing.FontStyle.Bold);
+            this.btnExit.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(51)))), ((int)(((byte)(65)))), ((int)(((byte)(85)))));
+            this.btnExit.Location = new System.Drawing.Point(230, 305);
+            this.btnExit.Name = "btnExit";
+            this.btnExit.Size = new System.Drawing.Size(150, 40);
+            this.btnExit.TabIndex = 7;
+            this.btnExit.Text = "Выход";
+            this.btnExit.UseVisualStyleBackColor = false;
+            this.btnExit.Click += new System.EventHandler(this.BtnExit_Click);
+            // 
+            // btnLogin
+            // 
+            this.btnLogin.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(37)))), ((int)(((byte)(99)))), ((int)(((byte)(235)))));
+            this.btnLogin.FlatAppearance.BorderSize = 0;
+            this.btnLogin.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
+            this.btnLogin.Font = new System.Drawing.Font("Segoe UI", 10F, System.Drawing.FontStyle.Bold);
+            this.btnLogin.ForeColor = System.Drawing.Color.White;
+            this.btnLogin.Location = new System.Drawing.Point(60, 305);
+            this.btnLogin.Name = "btnLogin";
+            this.btnLogin.Size = new System.Drawing.Size(150, 40);
+            this.btnLogin.TabIndex = 6;
+            this.btnLogin.Text = "Войти";
+            this.btnLogin.UseVisualStyleBackColor = false;
+            this.btnLogin.Click += new System.EventHandler(this.BtnLogin_Click);
+            // 
+            // lblError
+            // 
+            this.lblError.Font = new System.Drawing.Font("Segoe UI", 9F);
+            this.lblError.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(185)))), ((int)(((byte)(28)))), ((int)(((byte)(28)))));
+            this.lblError.Location = new System.Drawing.Point(60, 258);
+            this.lblError.Name = "lblError";
+            this.lblError.Size = new System.Drawing.Size(320, 34);
+            this.lblError.TabIndex = 5;
+            // 
+            // txtPassword
+            // 
+            this.txtPassword.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
+            this.txtPassword.Font = new System.Drawing.Font("Segoe UI", 10F);
+            this.txtPassword.Location = new System.Drawing.Point(60, 222);
+            this.txtPassword.Name = "txtPassword";
+            this.txtPassword.PasswordChar = '•';
+            this.txtPassword.Size = new System.Drawing.Size(320, 25);
+            this.txtPassword.TabIndex = 4;
+            this.txtPassword.KeyDown += new System.Windows.Forms.KeyEventHandler(this.TxtPassword_KeyDown);
+            // 
+            // lblPassword
+            // 
+            this.lblPassword.AutoSize = true;
+            this.lblPassword.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(51)))), ((int)(((byte)(65)))), ((int)(((byte)(85)))));
+            this.lblPassword.Location = new System.Drawing.Point(60, 198);
+            this.lblPassword.Name = "lblPassword";
+            this.lblPassword.Size = new System.Drawing.Size(54, 17);
+            this.lblPassword.TabIndex = 3;
+            this.lblPassword.Text = "Пароль";
+            // 
+            // txtLogin
+            // 
+            this.txtLogin.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
+            this.txtLogin.Font = new System.Drawing.Font("Segoe UI", 10F);
+            this.txtLogin.Location = new System.Drawing.Point(60, 146);
+            this.txtLogin.Name = "txtLogin";
+            this.txtLogin.Size = new System.Drawing.Size(320, 25);
+            this.txtLogin.TabIndex = 2;
+            // 
+            // lblLogin
+            // 
+            this.lblLogin.AutoSize = true;
+            this.lblLogin.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(51)))), ((int)(((byte)(65)))), ((int)(((byte)(85)))));
+            this.lblLogin.Location = new System.Drawing.Point(60, 122);
+            this.lblLogin.Name = "lblLogin";
+            this.lblLogin.Size = new System.Drawing.Size(44, 17);
+            this.lblLogin.TabIndex = 1;
+            this.lblLogin.Text = "Логин";
+            // 
+            // lblTitle
+            // 
+            this.lblTitle.AutoSize = true;
+            this.lblTitle.Font = new System.Drawing.Font("Segoe UI", 20F, System.Drawing.FontStyle.Bold);
+            this.lblTitle.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(15)))), ((int)(((byte)(23)))), ((int)(((byte)(42)))));
+            this.lblTitle.Location = new System.Drawing.Point(56, 58);
+            this.lblTitle.Name = "lblTitle";
+            this.lblTitle.Size = new System.Drawing.Size(219, 37);
+            this.lblTitle.TabIndex = 0;
+            this.lblTitle.Text = "Вход в систему";
+            // 
+            // LoginForm
+            // 
+            this.AcceptButton = this.btnLogin;
+            this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 17F);
+            this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
+            this.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(248)))), ((int)(((byte)(250)))), ((int)(((byte)(252)))));
+            this.CancelButton = this.btnExit;
+            this.ClientSize = new System.Drawing.Size(760, 420);
+            this.Controls.Add(this.pnlContent);
+            this.Controls.Add(this.pnlSidebar);
+            this.Font = new System.Drawing.Font("Segoe UI", 9.5F);
+            this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedSingle;
+            this.MaximizeBox = false;
+            this.MinimizeBox = false;
+            this.Name = "LoginForm";
+            this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen;
+            this.Text = "Авторизация | Автошкола";
+            this.pnlSidebar.ResumeLayout(false);
+            this.pnlContent.ResumeLayout(false);
+            this.pnlContent.PerformLayout();
+            this.ResumeLayout(false);
+
+        }
+    }
+}

+ 268 - 0
Autoschool_PM11/autoschool/WindowsFormsApp6/LoginForm.cs

@@ -0,0 +1,268 @@
+using System;
+using System.Configuration;
+using System.Windows.Forms;
+using Npgsql;
+using NpgsqlTypes;
+
+namespace SimpleDBViewer
+{
+    public partial class LoginForm : Form
+    {
+        public LoginForm()
+        {
+            InitializeComponent();
+        }
+
+        private void TxtPassword_KeyDown(object sender, KeyEventArgs e)
+        {
+            if (e.KeyCode == Keys.Enter)
+            {
+                e.SuppressKeyPress = true;
+                BtnLogin_Click(sender, EventArgs.Empty);
+            }
+        }
+
+        private void BtnLogin_Click(object sender, EventArgs e)
+        {
+            string login = txtLogin.Text.Trim();
+            string password = txtPassword.Text;
+
+            lblError.Text = "";
+
+            if (string.IsNullOrWhiteSpace(login))
+            {
+                lblError.Text = "Введите логин.";
+                txtLogin.Focus();
+                return;
+            }
+
+            if (string.IsNullOrWhiteSpace(password))
+            {
+                lblError.Text = "Введите пароль.";
+                txtPassword.Focus();
+                return;
+            }
+
+            try
+            {
+                btnLogin.Enabled = false;
+                btnLogin.Text = "Проверка...";
+                Application.DoEvents();
+
+                using (NpgsqlConnection connection = new NpgsqlConnection(DbSettings.ConnectionString))
+                {
+                    connection.Open();
+
+                    const string sql = @"
+                        select user_id, login, role::text as role, instructor_id, driver_id
+                        from akulova_vs.fn_authenticate_user(cast(@login as text), cast(@password as text))";
+
+                    using (NpgsqlCommand command = new NpgsqlCommand(sql, connection))
+                    {
+                        command.Parameters.AddWithValue("@login", login);
+                        command.Parameters.AddWithValue("@password", password);
+
+                        using (NpgsqlDataReader reader = command.ExecuteReader())
+                        {
+                            if (!reader.Read())
+                            {
+                                lblError.Text = "Неверный логин или пароль.";
+                                return;
+                            }
+
+                            UserSession.CurrentUser = new UserInfo
+                            {
+                                UserId = Convert.ToInt32(reader["user_id"]),
+                                Login = reader["login"].ToString(),
+                                Role = reader["role"].ToString(),
+                                InstructorId = reader["instructor_id"] == DBNull.Value
+                                    ? (int?)null
+                                    : Convert.ToInt32(reader["instructor_id"]),
+                                DriverId = reader["driver_id"] == DBNull.Value
+                                    ? (int?)null
+                                    : Convert.ToInt32(reader["driver_id"])
+                            };
+                        }
+                    }
+                }
+
+                using (MainForm mainForm = new MainForm())
+                {
+                    Hide();
+                    mainForm.ShowDialog();
+                    txtPassword.Clear();
+                    Show();
+                    txtPassword.Focus();
+                }
+            }
+            catch (Exception ex)
+            {
+                MessageBox.Show(AppError.ToUserMessage(ex, "Не удалось выполнить вход."),
+                    "Ошибка подключения или авторизации", MessageBoxButtons.OK, MessageBoxIcon.Error);
+            }
+            finally
+            {
+                btnLogin.Enabled = true;
+                btnLogin.Text = "Войти";
+            }
+        }
+
+        private void BtnExit_Click(object sender, EventArgs e)
+        {
+            Application.Exit();
+        }
+
+        private void lblInfo_Click(object sender, EventArgs e)
+        {
+
+        }
+    }
+
+    public static class DbSettings
+    {
+        public static string ConnectionString
+        {
+            get
+            {
+                ConnectionStringSettings settings = ConfigurationManager.ConnectionStrings["AutoschoolDb"];
+                return settings.ConnectionString;
+            }
+        }
+    }
+
+    public class UserInfo
+    {
+        public int UserId { get; set; }
+        public string Login { get; set; }
+        public string Role { get; set; }
+        public int? InstructorId { get; set; }
+        public int? DriverId { get; set; }
+    }
+
+    public static class DbAppContext
+    {
+        public static void Apply(NpgsqlConnection connection)
+        {
+            if (connection == null || !UserSession.IsLoggedIn)
+                return;
+
+            using (NpgsqlCommand command = new NpgsqlCommand(@"
+                select set_config('app.user_login', cast(@login as text), false);", connection))
+            {
+                command.Parameters.AddWithValue("@login", UserSession.CurrentUser.Login ?? "system");
+                command.ExecuteNonQuery();
+            }
+        }
+
+        public static void MarkLatestAuditLogin(NpgsqlConnection connection, string tableName)
+        {
+            MarkLatestAuditLogin(connection, tableName, null);
+        }
+
+        public static void MarkLatestAuditLogin(NpgsqlConnection connection, string tableName, object recordId)
+        {
+            if (connection == null || !UserSession.IsLoggedIn || string.IsNullOrWhiteSpace(tableName))
+                return;
+
+            int parsedRecordId;
+            object recordIdValue = DBNull.Value;
+            if (recordId != null && recordId != DBNull.Value && int.TryParse(recordId.ToString(), out parsedRecordId))
+                recordIdValue = parsedRecordId;
+
+            using (NpgsqlCommand command = new NpgsqlCommand(@"
+                update akulova_vs.audit_log
+                set changed_by = cast(@login as varchar)
+                where log_id = (
+                    select log_id
+                    from akulova_vs.audit_log
+                    where table_name = cast(@table_name as text)
+                      and (cast(@record_id as integer) is null or record_id = cast(@record_id as integer))
+                      and changed_at >= now() - interval '5 minutes'
+                    order by log_id desc
+                    limit 1
+                );", connection))
+            {
+                command.Parameters.AddWithValue("@login", UserSession.CurrentUser.Login ?? "system");
+                command.Parameters.AddWithValue("@table_name", tableName);
+                NpgsqlParameter recordParameter = command.Parameters.Add("@record_id", NpgsqlDbType.Integer);
+                recordParameter.Value = recordIdValue;
+                command.ExecuteNonQuery();
+            }
+        }
+    }
+
+    public static class UserSession
+    {
+        public static UserInfo CurrentUser { get; set; }
+
+        public static bool IsLoggedIn
+        {
+            get { return CurrentUser != null; }
+        }
+
+        public static bool IsAdmin
+        {
+            get { return IsLoggedIn && CurrentUser.Role == "admin"; }
+        }
+
+        public static void Logout()
+        {
+            CurrentUser = null;
+        }
+    }
+
+    public static class AppError
+    {
+        public static string ToUserMessage(Exception ex, string actionText)
+        {
+            if (ex is ArgumentException)
+                return ex.Message;
+
+            PostgresException pg = ex as PostgresException;
+            if (pg != null)
+            {
+                switch (pg.SqlState)
+                {
+                    case "23505":
+                        return actionText + "\n\nТакая запись уже существует. Проверьте уникальные поля: логин, номер удостоверения или номер водительских прав.";
+                    case "23503":
+                        return actionText + "\n\nЗапись нельзя сохранить или удалить, потому что она связана с другими данными. Сначала проверьте связанные таблицы.";
+                    case "23502":
+                        return actionText + "\n\nНе заполнено обязательное поле. Проверьте форму и заполните все обязательные данные.";
+                    case "23514":
+                        return actionText + "\n\nЗначение не проходит проверку базы данных. Проверьте выбранный результат экзамена, сложность маршрута, категорию прав или дату рождения.";
+                    case "22P02":
+                    case "22007":
+                    case "22008":
+                        return actionText + "\n\nНекорректный формат данных. Проверьте числа и даты.";
+                    case "42P01":
+                        return actionText + "\n\nТаблица или представление не найдено. Проверьте, что объекты созданы в схеме akulova_vs и в строке подключения указан Search Path=akulova_vs,public.";
+                    case "42883":
+                        return actionText + "\n\nФункция базы данных не найдена. Проверьте, что функции fn_authenticate_user и fn_hash_password созданы в схеме akulova_vs.";
+                    case "42703":
+                        return actionText + "\n\nВ базе данных не найден нужный столбец. Проверьте, что структура таблиц соответствует ТЗ.";
+                    case "42501":
+                        return actionText + "\n\nНедостаточно прав для выполнения операции в базе данных.";
+                    case "3D000":
+                        return actionText + "\n\nБаза данных не найдена. Проверьте имя базы в App.config.";
+                    case "28P01":
+                        return actionText + "\n\nНеверный логин или пароль подключения к PostgreSQL. Проверьте Username и Password в App.config.";
+                    default:
+                        return actionText + "\n\nБаза данных вернула ошибку. Код: " + pg.SqlState + ".";
+                }
+            }
+
+            if (ex is NpgsqlException)
+            {
+                return actionText + "\n\nНе удалось подключиться к PostgreSQL. Проверьте сервер, порт, базу данных и строку подключения в App.config.";
+            }
+
+            if (ex is FormatException || ex is InvalidCastException)
+            {
+                return actionText + "\n\nНекорректный формат данных. Проверьте, что числовые поля заполнены числами, а даты выбраны через календарь.";
+            }
+
+            return actionText + "\n\nПроизошла ошибка при выполнении операции. Проверьте введённые данные и попробуйте ещё раз.";
+        }
+    }
+}

+ 120 - 0
Autoschool_PM11/autoschool/WindowsFormsApp6/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>

+ 531 - 0
Autoschool_PM11/autoschool/WindowsFormsApp6/MainForm.Designer.cs

@@ -0,0 +1,531 @@
+namespace SimpleDBViewer
+{
+    partial class MainForm
+    {
+        private System.ComponentModel.IContainer components = null;
+        private System.Windows.Forms.Panel pnlHeader;
+        private System.Windows.Forms.Label lblTitle;
+        private System.Windows.Forms.Label lblUser;
+        private System.Windows.Forms.Button btnLogout;
+        private System.Windows.Forms.TabControl tabControl;
+        private System.Windows.Forms.TabPage tabTables;
+        private System.Windows.Forms.TabPage tabSearch;
+        private System.Windows.Forms.Panel pnlTablesTop;
+        private System.Windows.Forms.Label lblTableCaption;
+        private System.Windows.Forms.ComboBox cmbTables;
+        private System.Windows.Forms.Button btnRefresh;
+        private System.Windows.Forms.Button btnAdd;
+        private System.Windows.Forms.Button btnEdit;
+        private System.Windows.Forms.Button btnDelete;
+        private System.Windows.Forms.Button btnExportTable;
+        private System.Windows.Forms.Label lblTableStatus;
+        private System.Windows.Forms.DataGridView dgvTables;
+        private System.Windows.Forms.Panel pnlSearchTop;
+        private System.Windows.Forms.Label lblDriverFilter;
+        private System.Windows.Forms.TextBox txtDriverFilter;
+        private System.Windows.Forms.Label lblSchoolFilter;
+        private System.Windows.Forms.TextBox txtSchoolFilter;
+        private System.Windows.Forms.Label lblResultFilter;
+        private System.Windows.Forms.ComboBox cmbResultFilter;
+        private System.Windows.Forms.Label lblDateFrom;
+        private System.Windows.Forms.DateTimePicker dtFrom;
+        private System.Windows.Forms.Label lblDateTo;
+        private System.Windows.Forms.DateTimePicker dtTo;
+        private System.Windows.Forms.Button btnSearch;
+        private System.Windows.Forms.Button btnReset;
+        private System.Windows.Forms.Button btnExportSearch;
+        private System.Windows.Forms.DataGridView dgvSearch;
+
+        protected override void Dispose(bool disposing)
+        {
+            if (disposing && (components != null))
+            {
+                components.Dispose();
+            }
+            base.Dispose(disposing);
+        }
+
+        private void InitializeComponent()
+        {
+            this.pnlHeader = new System.Windows.Forms.Panel();
+            this.btnLogout = new System.Windows.Forms.Button();
+            this.lblUser = new System.Windows.Forms.Label();
+            this.lblTitle = new System.Windows.Forms.Label();
+            this.tabControl = new System.Windows.Forms.TabControl();
+            this.tabTables = new System.Windows.Forms.TabPage();
+            this.dgvTables = new System.Windows.Forms.DataGridView();
+            this.pnlTablesTop = new System.Windows.Forms.Panel();
+            this.lblTableStatus = new System.Windows.Forms.Label();
+            this.btnExportTable = new System.Windows.Forms.Button();
+            this.btnDelete = new System.Windows.Forms.Button();
+            this.btnEdit = new System.Windows.Forms.Button();
+            this.btnAdd = new System.Windows.Forms.Button();
+            this.btnRefresh = new System.Windows.Forms.Button();
+            this.cmbTables = new System.Windows.Forms.ComboBox();
+            this.lblTableCaption = new System.Windows.Forms.Label();
+            this.tabSearch = new System.Windows.Forms.TabPage();
+            this.dgvSearch = new System.Windows.Forms.DataGridView();
+            this.pnlSearchTop = new System.Windows.Forms.Panel();
+            this.btnExportSearch = new System.Windows.Forms.Button();
+            this.btnReset = new System.Windows.Forms.Button();
+            this.btnSearch = new System.Windows.Forms.Button();
+            this.dtTo = new System.Windows.Forms.DateTimePicker();
+            this.lblDateTo = new System.Windows.Forms.Label();
+            this.dtFrom = new System.Windows.Forms.DateTimePicker();
+            this.lblDateFrom = new System.Windows.Forms.Label();
+            this.cmbResultFilter = new System.Windows.Forms.ComboBox();
+            this.lblResultFilter = new System.Windows.Forms.Label();
+            this.txtSchoolFilter = new System.Windows.Forms.TextBox();
+            this.lblSchoolFilter = new System.Windows.Forms.Label();
+            this.txtDriverFilter = new System.Windows.Forms.TextBox();
+            this.lblDriverFilter = new System.Windows.Forms.Label();
+            this.pnlHeader.SuspendLayout();
+            this.tabControl.SuspendLayout();
+            this.tabTables.SuspendLayout();
+            ((System.ComponentModel.ISupportInitialize)(this.dgvTables)).BeginInit();
+            this.pnlTablesTop.SuspendLayout();
+            this.tabSearch.SuspendLayout();
+            ((System.ComponentModel.ISupportInitialize)(this.dgvSearch)).BeginInit();
+            this.pnlSearchTop.SuspendLayout();
+            this.SuspendLayout();
+            // 
+            // pnlHeader
+            // 
+            this.pnlHeader.BackColor = System.Drawing.Color.FromArgb(30, 58, 84);
+            this.pnlHeader.Controls.Add(this.btnLogout);
+            this.pnlHeader.Controls.Add(this.lblUser);
+            this.pnlHeader.Controls.Add(this.lblTitle);
+            this.pnlHeader.Dock = System.Windows.Forms.DockStyle.Top;
+            this.pnlHeader.Location = new System.Drawing.Point(0, 0);
+            this.pnlHeader.Name = "pnlHeader";
+            this.pnlHeader.Size = new System.Drawing.Size(1180, 76);
+            this.pnlHeader.TabIndex = 0;
+            // 
+            // btnLogout
+            // 
+            this.btnLogout.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right)));
+            this.btnLogout.BackColor = System.Drawing.Color.FromArgb(226, 232, 240);
+            this.btnLogout.FlatAppearance.BorderSize = 0;
+            this.btnLogout.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
+            this.btnLogout.Font = new System.Drawing.Font("Segoe UI", 9.5F, System.Drawing.FontStyle.Bold);
+            this.btnLogout.ForeColor = System.Drawing.Color.FromArgb(30, 58, 84);
+            this.btnLogout.Location = new System.Drawing.Point(1054, 21);
+            this.btnLogout.Name = "btnLogout";
+            this.btnLogout.Size = new System.Drawing.Size(104, 34);
+            this.btnLogout.TabIndex = 2;
+            this.btnLogout.Text = "Выйти";
+            this.btnLogout.UseVisualStyleBackColor = false;
+            this.btnLogout.Click += new System.EventHandler(this.BtnLogout_Click);
+            // 
+            // lblUser
+            // 
+            this.lblUser.AutoSize = true;
+            this.lblUser.Font = new System.Drawing.Font("Segoe UI", 10F);
+            this.lblUser.ForeColor = System.Drawing.Color.FromArgb(226, 232, 240);
+            this.lblUser.Location = new System.Drawing.Point(29, 48);
+            this.lblUser.Name = "lblUser";
+            this.lblUser.Size = new System.Drawing.Size(0, 19);
+            this.lblUser.TabIndex = 1;
+            // 
+            // lblTitle
+            // 
+            this.lblTitle.AutoSize = true;
+            this.lblTitle.Font = new System.Drawing.Font("Segoe UI", 18F, System.Drawing.FontStyle.Bold);
+            this.lblTitle.ForeColor = System.Drawing.Color.White;
+            this.lblTitle.Location = new System.Drawing.Point(26, 13);
+            this.lblTitle.Name = "lblTitle";
+            this.lblTitle.Size = new System.Drawing.Size(142, 32);
+            this.lblTitle.TabIndex = 0;
+            this.lblTitle.Text = "Автошкола";
+            // 
+            // tabControl
+            // 
+            this.tabControl.Controls.Add(this.tabTables);
+            this.tabControl.Controls.Add(this.tabSearch);
+            this.tabControl.Dock = System.Windows.Forms.DockStyle.Fill;
+            this.tabControl.Location = new System.Drawing.Point(0, 76);
+            this.tabControl.Name = "tabControl";
+            this.tabControl.SelectedIndex = 0;
+            this.tabControl.Size = new System.Drawing.Size(1180, 684);
+            this.tabControl.TabIndex = 1;
+            // 
+            // tabTables
+            // 
+            this.tabTables.BackColor = System.Drawing.Color.FromArgb(248, 250, 252);
+            this.tabTables.Controls.Add(this.dgvTables);
+            this.tabTables.Controls.Add(this.pnlTablesTop);
+            this.tabTables.Location = new System.Drawing.Point(4, 26);
+            this.tabTables.Name = "tabTables";
+            this.tabTables.Padding = new System.Windows.Forms.Padding(14);
+            this.tabTables.Size = new System.Drawing.Size(1172, 654);
+            this.tabTables.TabIndex = 0;
+            this.tabTables.Text = "Данные";
+            // 
+            // dgvTables
+            // 
+            this.dgvTables.AllowUserToAddRows = false;
+            this.dgvTables.AllowUserToDeleteRows = false;
+            this.dgvTables.AutoSizeColumnsMode = System.Windows.Forms.DataGridViewAutoSizeColumnsMode.AllCells;
+            this.dgvTables.BackgroundColor = System.Drawing.Color.White;
+            this.dgvTables.BorderStyle = System.Windows.Forms.BorderStyle.None;
+            this.dgvTables.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
+            this.dgvTables.Dock = System.Windows.Forms.DockStyle.Fill;
+            this.dgvTables.EnableHeadersVisualStyles = false;
+            this.dgvTables.GridColor = System.Drawing.Color.FromArgb(226, 232, 240);
+            this.dgvTables.Location = new System.Drawing.Point(14, 96);
+            this.dgvTables.MultiSelect = false;
+            this.dgvTables.Name = "dgvTables";
+            this.dgvTables.ReadOnly = true;
+            this.dgvTables.RowHeadersVisible = false;
+            this.dgvTables.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect;
+            this.dgvTables.Size = new System.Drawing.Size(1144, 544);
+            this.dgvTables.TabIndex = 1;
+            // 
+            // pnlTablesTop
+            // 
+            this.pnlTablesTop.BackColor = System.Drawing.Color.FromArgb(248, 250, 252);
+            this.pnlTablesTop.Controls.Add(this.lblTableStatus);
+            this.pnlTablesTop.Controls.Add(this.btnExportTable);
+            this.pnlTablesTop.Controls.Add(this.btnDelete);
+            this.pnlTablesTop.Controls.Add(this.btnEdit);
+            this.pnlTablesTop.Controls.Add(this.btnAdd);
+            this.pnlTablesTop.Controls.Add(this.btnRefresh);
+            this.pnlTablesTop.Controls.Add(this.cmbTables);
+            this.pnlTablesTop.Controls.Add(this.lblTableCaption);
+            this.pnlTablesTop.Dock = System.Windows.Forms.DockStyle.Top;
+            this.pnlTablesTop.Location = new System.Drawing.Point(14, 14);
+            this.pnlTablesTop.Name = "pnlTablesTop";
+            this.pnlTablesTop.Size = new System.Drawing.Size(1144, 82);
+            this.pnlTablesTop.TabIndex = 0;
+            // 
+            // lblTableStatus
+            // 
+            this.lblTableStatus.AutoSize = true;
+            this.lblTableStatus.ForeColor = System.Drawing.Color.FromArgb(100, 116, 139);
+            this.lblTableStatus.Location = new System.Drawing.Point(0, 55);
+            this.lblTableStatus.Name = "lblTableStatus";
+            this.lblTableStatus.Size = new System.Drawing.Size(0, 17);
+            this.lblTableStatus.TabIndex = 7;
+            // 
+            // btnExportTable
+            // 
+            this.btnExportTable.BackColor = System.Drawing.Color.FromArgb(71, 85, 105);
+            this.btnExportTable.FlatAppearance.BorderSize = 0;
+            this.btnExportTable.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
+            this.btnExportTable.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold);
+            this.btnExportTable.ForeColor = System.Drawing.Color.White;
+            this.btnExportTable.Location = new System.Drawing.Point(826, 8);
+            this.btnExportTable.Name = "btnExportTable";
+            this.btnExportTable.Size = new System.Drawing.Size(120, 34);
+            this.btnExportTable.TabIndex = 6;
+            this.btnExportTable.Text = "Отчёт CSV";
+            this.btnExportTable.UseVisualStyleBackColor = false;
+            this.btnExportTable.Click += new System.EventHandler(this.BtnExportTable_Click);
+            // 
+            // btnDelete
+            // 
+            this.btnDelete.BackColor = System.Drawing.Color.FromArgb(239, 68, 68);
+            this.btnDelete.FlatAppearance.BorderSize = 0;
+            this.btnDelete.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
+            this.btnDelete.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold);
+            this.btnDelete.ForeColor = System.Drawing.Color.White;
+            this.btnDelete.Location = new System.Drawing.Point(714, 8);
+            this.btnDelete.Name = "btnDelete";
+            this.btnDelete.Size = new System.Drawing.Size(100, 34);
+            this.btnDelete.TabIndex = 5;
+            this.btnDelete.Text = "Удалить";
+            this.btnDelete.UseVisualStyleBackColor = false;
+            this.btnDelete.Click += new System.EventHandler(this.BtnDelete_Click);
+            // 
+            // btnEdit
+            // 
+            this.btnEdit.BackColor = System.Drawing.Color.FromArgb(245, 158, 11);
+            this.btnEdit.FlatAppearance.BorderSize = 0;
+            this.btnEdit.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
+            this.btnEdit.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold);
+            this.btnEdit.ForeColor = System.Drawing.Color.White;
+            this.btnEdit.Location = new System.Drawing.Point(602, 8);
+            this.btnEdit.Name = "btnEdit";
+            this.btnEdit.Size = new System.Drawing.Size(100, 34);
+            this.btnEdit.TabIndex = 4;
+            this.btnEdit.Text = "Изменить";
+            this.btnEdit.UseVisualStyleBackColor = false;
+            this.btnEdit.Click += new System.EventHandler(this.BtnEdit_Click);
+            // 
+            // btnAdd
+            // 
+            this.btnAdd.BackColor = System.Drawing.Color.FromArgb(34, 197, 94);
+            this.btnAdd.FlatAppearance.BorderSize = 0;
+            this.btnAdd.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
+            this.btnAdd.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold);
+            this.btnAdd.ForeColor = System.Drawing.Color.White;
+            this.btnAdd.Location = new System.Drawing.Point(490, 8);
+            this.btnAdd.Name = "btnAdd";
+            this.btnAdd.Size = new System.Drawing.Size(100, 34);
+            this.btnAdd.TabIndex = 3;
+            this.btnAdd.Text = "Добавить";
+            this.btnAdd.UseVisualStyleBackColor = false;
+            this.btnAdd.Click += new System.EventHandler(this.BtnAdd_Click);
+            // 
+            // btnRefresh
+            // 
+            this.btnRefresh.BackColor = System.Drawing.Color.FromArgb(14, 165, 233);
+            this.btnRefresh.FlatAppearance.BorderSize = 0;
+            this.btnRefresh.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
+            this.btnRefresh.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold);
+            this.btnRefresh.ForeColor = System.Drawing.Color.White;
+            this.btnRefresh.Location = new System.Drawing.Point(378, 8);
+            this.btnRefresh.Name = "btnRefresh";
+            this.btnRefresh.Size = new System.Drawing.Size(100, 34);
+            this.btnRefresh.TabIndex = 2;
+            this.btnRefresh.Text = "Обновить";
+            this.btnRefresh.UseVisualStyleBackColor = false;
+            this.btnRefresh.Click += new System.EventHandler(this.BtnRefresh_Click);
+            // 
+            // cmbTables
+            // 
+            this.cmbTables.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
+            this.cmbTables.FormattingEnabled = true;
+            this.cmbTables.Location = new System.Drawing.Point(68, 12);
+            this.cmbTables.Name = "cmbTables";
+            this.cmbTables.Size = new System.Drawing.Size(285, 25);
+            this.cmbTables.TabIndex = 1;
+            this.cmbTables.SelectedIndexChanged += new System.EventHandler(this.CmbTables_SelectedIndexChanged);
+            // 
+            // lblTableCaption
+            // 
+            this.lblTableCaption.AutoSize = true;
+            this.lblTableCaption.ForeColor = System.Drawing.Color.FromArgb(51, 65, 85);
+            this.lblTableCaption.Location = new System.Drawing.Point(0, 15);
+            this.lblTableCaption.Name = "lblTableCaption";
+            this.lblTableCaption.Size = new System.Drawing.Size(53, 17);
+            this.lblTableCaption.TabIndex = 0;
+            this.lblTableCaption.Text = "Раздел";
+            // 
+            // tabSearch
+            // 
+            this.tabSearch.BackColor = System.Drawing.Color.FromArgb(248, 250, 252);
+            this.tabSearch.Controls.Add(this.dgvSearch);
+            this.tabSearch.Controls.Add(this.pnlSearchTop);
+            this.tabSearch.Location = new System.Drawing.Point(4, 26);
+            this.tabSearch.Name = "tabSearch";
+            this.tabSearch.Padding = new System.Windows.Forms.Padding(14);
+            this.tabSearch.Size = new System.Drawing.Size(1172, 654);
+            this.tabSearch.TabIndex = 1;
+            this.tabSearch.Text = "Поиск экзаменов";
+            // 
+            // dgvSearch
+            // 
+            this.dgvSearch.AllowUserToAddRows = false;
+            this.dgvSearch.AllowUserToDeleteRows = false;
+            this.dgvSearch.AutoSizeColumnsMode = System.Windows.Forms.DataGridViewAutoSizeColumnsMode.AllCells;
+            this.dgvSearch.BackgroundColor = System.Drawing.Color.White;
+            this.dgvSearch.BorderStyle = System.Windows.Forms.BorderStyle.None;
+            this.dgvSearch.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
+            this.dgvSearch.Dock = System.Windows.Forms.DockStyle.Fill;
+            this.dgvSearch.EnableHeadersVisualStyles = false;
+            this.dgvSearch.GridColor = System.Drawing.Color.FromArgb(226, 232, 240);
+            this.dgvSearch.Location = new System.Drawing.Point(14, 116);
+            this.dgvSearch.MultiSelect = false;
+            this.dgvSearch.Name = "dgvSearch";
+            this.dgvSearch.ReadOnly = true;
+            this.dgvSearch.RowHeadersVisible = false;
+            this.dgvSearch.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect;
+            this.dgvSearch.Size = new System.Drawing.Size(1144, 524);
+            this.dgvSearch.TabIndex = 1;
+            // 
+            // pnlSearchTop
+            // 
+            this.pnlSearchTop.BackColor = System.Drawing.Color.FromArgb(248, 250, 252);
+            this.pnlSearchTop.Controls.Add(this.btnExportSearch);
+            this.pnlSearchTop.Controls.Add(this.btnReset);
+            this.pnlSearchTop.Controls.Add(this.btnSearch);
+            this.pnlSearchTop.Controls.Add(this.dtTo);
+            this.pnlSearchTop.Controls.Add(this.lblDateTo);
+            this.pnlSearchTop.Controls.Add(this.dtFrom);
+            this.pnlSearchTop.Controls.Add(this.lblDateFrom);
+            this.pnlSearchTop.Controls.Add(this.cmbResultFilter);
+            this.pnlSearchTop.Controls.Add(this.lblResultFilter);
+            this.pnlSearchTop.Controls.Add(this.txtSchoolFilter);
+            this.pnlSearchTop.Controls.Add(this.lblSchoolFilter);
+            this.pnlSearchTop.Controls.Add(this.txtDriverFilter);
+            this.pnlSearchTop.Controls.Add(this.lblDriverFilter);
+            this.pnlSearchTop.Dock = System.Windows.Forms.DockStyle.Top;
+            this.pnlSearchTop.Location = new System.Drawing.Point(14, 14);
+            this.pnlSearchTop.Name = "pnlSearchTop";
+            this.pnlSearchTop.Size = new System.Drawing.Size(1144, 102);
+            this.pnlSearchTop.TabIndex = 0;
+            // 
+            // btnExportSearch
+            // 
+            this.btnExportSearch.BackColor = System.Drawing.Color.FromArgb(71, 85, 105);
+            this.btnExportSearch.FlatAppearance.BorderSize = 0;
+            this.btnExportSearch.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
+            this.btnExportSearch.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold);
+            this.btnExportSearch.ForeColor = System.Drawing.Color.White;
+            this.btnExportSearch.Location = new System.Drawing.Point(258, 58);
+            this.btnExportSearch.Name = "btnExportSearch";
+            this.btnExportSearch.Size = new System.Drawing.Size(120, 34);
+            this.btnExportSearch.TabIndex = 12;
+            this.btnExportSearch.Text = "Отчёт CSV";
+            this.btnExportSearch.UseVisualStyleBackColor = false;
+            this.btnExportSearch.Click += new System.EventHandler(this.BtnExportSearch_Click);
+            // 
+            // btnReset
+            // 
+            this.btnReset.BackColor = System.Drawing.Color.FromArgb(100, 116, 139);
+            this.btnReset.FlatAppearance.BorderSize = 0;
+            this.btnReset.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
+            this.btnReset.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold);
+            this.btnReset.ForeColor = System.Drawing.Color.White;
+            this.btnReset.Location = new System.Drawing.Point(132, 58);
+            this.btnReset.Name = "btnReset";
+            this.btnReset.Size = new System.Drawing.Size(110, 34);
+            this.btnReset.TabIndex = 11;
+            this.btnReset.Text = "Сбросить";
+            this.btnReset.UseVisualStyleBackColor = false;
+            this.btnReset.Click += new System.EventHandler(this.BtnReset_Click);
+            // 
+            // btnSearch
+            // 
+            this.btnSearch.BackColor = System.Drawing.Color.FromArgb(37, 99, 235);
+            this.btnSearch.FlatAppearance.BorderSize = 0;
+            this.btnSearch.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
+            this.btnSearch.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold);
+            this.btnSearch.ForeColor = System.Drawing.Color.White;
+            this.btnSearch.Location = new System.Drawing.Point(0, 58);
+            this.btnSearch.Name = "btnSearch";
+            this.btnSearch.Size = new System.Drawing.Size(116, 34);
+            this.btnSearch.TabIndex = 10;
+            this.btnSearch.Text = "Найти";
+            this.btnSearch.UseVisualStyleBackColor = false;
+            this.btnSearch.Click += new System.EventHandler(this.BtnSearch_Click);
+            // 
+            // dtTo
+            // 
+            this.dtTo.Format = System.Windows.Forms.DateTimePickerFormat.Short;
+            this.dtTo.Location = new System.Drawing.Point(786, 24);
+            this.dtTo.Name = "dtTo";
+            this.dtTo.ShowCheckBox = true;
+            this.dtTo.Size = new System.Drawing.Size(145, 24);
+            this.dtTo.TabIndex = 9;
+            this.dtTo.Checked = false;
+            // 
+            // lblDateTo
+            // 
+            this.lblDateTo.AutoSize = true;
+            this.lblDateTo.ForeColor = System.Drawing.Color.FromArgb(51, 65, 85);
+            this.lblDateTo.Location = new System.Drawing.Point(783, 3);
+            this.lblDateTo.Name = "lblDateTo";
+            this.lblDateTo.Size = new System.Drawing.Size(22, 17);
+            this.lblDateTo.TabIndex = 8;
+            this.lblDateTo.Text = "По";
+            // 
+            // dtFrom
+            // 
+            this.dtFrom.Format = System.Windows.Forms.DateTimePickerFormat.Short;
+            this.dtFrom.Location = new System.Drawing.Point(625, 24);
+            this.dtFrom.Name = "dtFrom";
+            this.dtFrom.ShowCheckBox = true;
+            this.dtFrom.Size = new System.Drawing.Size(145, 24);
+            this.dtFrom.TabIndex = 7;
+            this.dtFrom.Checked = false;
+            // 
+            // lblDateFrom
+            // 
+            this.lblDateFrom.AutoSize = true;
+            this.lblDateFrom.ForeColor = System.Drawing.Color.FromArgb(51, 65, 85);
+            this.lblDateFrom.Location = new System.Drawing.Point(622, 3);
+            this.lblDateFrom.Name = "lblDateFrom";
+            this.lblDateFrom.Size = new System.Drawing.Size(15, 17);
+            this.lblDateFrom.TabIndex = 6;
+            this.lblDateFrom.Text = "С";
+            // 
+            // cmbResultFilter
+            // 
+            this.cmbResultFilter.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
+            this.cmbResultFilter.FormattingEnabled = true;
+            this.cmbResultFilter.Items.AddRange(new object[] {
+            "Все",
+            "сдан",
+            "не сдан"});
+            this.cmbResultFilter.SelectedIndex = 0;
+            this.cmbResultFilter.Location = new System.Drawing.Point(476, 24);
+            this.cmbResultFilter.Name = "cmbResultFilter";
+            this.cmbResultFilter.Size = new System.Drawing.Size(130, 25);
+            this.cmbResultFilter.TabIndex = 5;
+            // 
+            // lblResultFilter
+            // 
+            this.lblResultFilter.AutoSize = true;
+            this.lblResultFilter.ForeColor = System.Drawing.Color.FromArgb(51, 65, 85);
+            this.lblResultFilter.Location = new System.Drawing.Point(473, 3);
+            this.lblResultFilter.Name = "lblResultFilter";
+            this.lblResultFilter.Size = new System.Drawing.Size(67, 17);
+            this.lblResultFilter.TabIndex = 4;
+            this.lblResultFilter.Text = "Результат";
+            // 
+            // txtSchoolFilter
+            // 
+            this.txtSchoolFilter.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
+            this.txtSchoolFilter.Location = new System.Drawing.Point(238, 24);
+            this.txtSchoolFilter.Name = "txtSchoolFilter";
+            this.txtSchoolFilter.Size = new System.Drawing.Size(220, 24);
+            this.txtSchoolFilter.TabIndex = 3;
+            // 
+            // lblSchoolFilter
+            // 
+            this.lblSchoolFilter.AutoSize = true;
+            this.lblSchoolFilter.ForeColor = System.Drawing.Color.FromArgb(51, 65, 85);
+            this.lblSchoolFilter.Location = new System.Drawing.Point(235, 3);
+            this.lblSchoolFilter.Name = "lblSchoolFilter";
+            this.lblSchoolFilter.Size = new System.Drawing.Size(77, 17);
+            this.lblSchoolFilter.TabIndex = 2;
+            this.lblSchoolFilter.Text = "Автошкола";
+            // 
+            // txtDriverFilter
+            // 
+            this.txtDriverFilter.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
+            this.txtDriverFilter.Location = new System.Drawing.Point(0, 24);
+            this.txtDriverFilter.Name = "txtDriverFilter";
+            this.txtDriverFilter.Size = new System.Drawing.Size(220, 24);
+            this.txtDriverFilter.TabIndex = 1;
+            // 
+            // lblDriverFilter
+            // 
+            this.lblDriverFilter.AutoSize = true;
+            this.lblDriverFilter.ForeColor = System.Drawing.Color.FromArgb(51, 65, 85);
+            this.lblDriverFilter.Location = new System.Drawing.Point(0, 3);
+            this.lblDriverFilter.Name = "lblDriverFilter";
+            this.lblDriverFilter.Size = new System.Drawing.Size(62, 17);
+            this.lblDriverFilter.TabIndex = 0;
+            this.lblDriverFilter.Text = "Водитель";
+            // 
+            // MainForm
+            // 
+            this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
+            this.BackColor = System.Drawing.Color.FromArgb(248, 250, 252);
+            this.ClientSize = new System.Drawing.Size(1180, 760);
+            this.Controls.Add(this.tabControl);
+            this.Controls.Add(this.pnlHeader);
+            this.Font = new System.Drawing.Font("Segoe UI", 9.5F);
+            this.MinimumSize = new System.Drawing.Size(1100, 700);
+            this.Name = "MainForm";
+            this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen;
+            this.Text = "Автошкола | WinForms + PostgreSQL";
+            this.pnlHeader.ResumeLayout(false);
+            this.pnlHeader.PerformLayout();
+            this.tabControl.ResumeLayout(false);
+            this.tabTables.ResumeLayout(false);
+            ((System.ComponentModel.ISupportInitialize)(this.dgvTables)).EndInit();
+            this.pnlTablesTop.ResumeLayout(false);
+            this.pnlTablesTop.PerformLayout();
+            this.tabSearch.ResumeLayout(false);
+            ((System.ComponentModel.ISupportInitialize)(this.dgvSearch)).EndInit();
+            this.pnlSearchTop.ResumeLayout(false);
+            this.pnlSearchTop.PerformLayout();
+            this.ResumeLayout(false);
+
+        }
+    }
+}

+ 799 - 0
Autoschool_PM11/autoschool/WindowsFormsApp6/MainForm.cs

@@ -0,0 +1,799 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Data;
+using System.Drawing;
+using System.IO;
+using System.Text;
+using System.Windows.Forms;
+using Npgsql;
+using NpgsqlTypes;
+
+namespace SimpleDBViewer
+{
+    public partial class MainForm : Form
+    {
+        public const string DbSchema = "akulova_vs";
+
+        private readonly List<TableInfo> tableInfos = new List<TableInfo>();
+        private TableInfo currentTable;
+        private DataTable currentTableData;
+        private DataTable currentSearchData;
+
+        public MainForm()
+        {
+            bool isDesigner = LicenseManager.UsageMode == LicenseUsageMode.Designtime;
+
+            if (!isDesigner && !UserSession.IsLoggedIn)
+            {
+                MessageBox.Show("Сначала выполните вход в систему.", "Доступ запрещён",
+                    MessageBoxButtons.OK, MessageBoxIcon.Warning);
+                Close();
+                return;
+            }
+
+            InitializeComponent();
+
+            if (isDesigner)
+                return;
+
+            BuildTableList();
+            ApplyPermissions();
+            LoadInitialData();
+        }
+
+        private void BuildTableList()
+        {
+            tableInfos.Clear();
+
+            if (!UserSession.IsAdmin)
+            {
+                tableInfos.Add(new TableInfo("personal_profile", "Мои данные", "driver_id"));
+                tableInfos.Add(new TableInfo("personal_exams", "Мои экзамены", "exam_id"));
+                tableInfos.Add(new TableInfo("personal_license", "Моё удостоверение", "license_id"));
+                return;
+            }
+
+            tableInfos.Add(new TableInfo("driving_schools", "Автошколы", "school_id"));
+            tableInfos.Add(new TableInfo("instructors", "Инструкторы", "instructor_id"));
+            tableInfos.Add(new TableInfo("drivers", "Водители", "driver_id"));
+            tableInfos.Add(new TableInfo("exam_routes", "Маршруты экзамена", "route_id"));
+            tableInfos.Add(new TableInfo("traffic_inspectors", "Инспекторы", "inspector_id"));
+            tableInfos.Add(new TableInfo("traffic_officers", "Сотрудники, выдающие права", "officer_id"));
+            tableInfos.Add(new TableInfo("exams", "Экзамены", "exam_id"));
+            tableInfos.Add(new TableInfo("licenses", "Водительские удостоверения", "license_id"));
+            tableInfos.Add(new TableInfo("app_users", "Пользователи приложения", "user_id"));
+            tableInfos.Add(new TableInfo("audit_log", "Журнал операций", "log_id", true));
+        }
+        private void ApplyPermissions()
+        {
+            lblUser.Text = UserSession.CurrentUser.Login + " · " + UserSession.CurrentUser.Role;
+
+            bool isAdmin = UserSession.IsAdmin;
+            btnAdd.Enabled = isAdmin;
+            btnEdit.Enabled = isAdmin;
+            btnDelete.Enabled = isAdmin;
+
+            if (!isAdmin)
+            {
+                lblTitle.Text = "Личный кабинет автошколы";
+                lblTableCaption.Text = "Раздел:";
+                tabTables.Text = "Личный кабинет";
+
+                btnAdd.Visible = false;
+                btnEdit.Visible = false;
+                btnDelete.Visible = false;
+                btnExportTable.Location = btnAdd.Location;
+
+                if (tabControl.TabPages.Contains(tabSearch))
+                    tabControl.TabPages.Remove(tabSearch);
+            }
+
+            UpdateActionButtons();
+        }
+
+        private void UpdateActionButtons()
+        {
+            bool canModify = UserSession.IsAdmin && currentTable != null && !currentTable.IsReadOnly;
+            if (btnAdd != null) btnAdd.Enabled = canModify;
+            if (btnEdit != null) btnEdit.Enabled = canModify;
+            if (btnDelete != null) btnDelete.Enabled = canModify;
+        }
+
+        private void LoadInitialData()
+        {
+            cmbTables.Items.Clear();
+            foreach (TableInfo table in tableInfos)
+            {
+                cmbTables.Items.Add(table);
+            }
+
+            if (cmbTables.Items.Count > 0)
+            {
+                cmbTables.SelectedIndex = 0;
+            }
+
+            if (UserSession.IsAdmin)
+                LoadExamSearch();
+        }
+
+        private void CmbTables_SelectedIndexChanged(object sender, EventArgs e)
+        {
+            currentTable = cmbTables.SelectedItem as TableInfo;
+            UpdateActionButtons();
+            LoadCurrentTable();
+        }
+
+        private void LoadCurrentTable()
+        {
+            if (currentTable == null)
+                return;
+
+            try
+            {
+                using (NpgsqlConnection connection = new NpgsqlConnection(DbSettings.ConnectionString))
+                {
+                    connection.Open();
+                    string sql = GetSelectSql(currentTable);
+                    using (NpgsqlCommand command = new NpgsqlCommand(sql, connection))
+                    {
+                        AddCurrentTableParameters(command);
+                        using (NpgsqlDataAdapter adapter = new NpgsqlDataAdapter(command))
+                        {
+                            currentTableData = new DataTable();
+                            adapter.Fill(currentTableData);
+                            dgvTables.DataSource = currentTableData;
+                            ApplyGridPresentation(dgvTables);
+                            lblTableStatus.Text = currentTable.DisplayName + ": записей — " + currentTableData.Rows.Count;
+                        }
+                    }
+                }
+            }
+            catch (Exception ex)
+            {
+                MessageBox.Show(AppError.ToUserMessage(ex, "Не удалось загрузить таблицу."),
+                    "Ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error);
+            }
+        }
+
+        private void AddCurrentTableParameters(NpgsqlCommand command)
+        {
+            if (currentTable == null)
+                return;
+
+            if (currentTable.Name == "personal_profile" || currentTable.Name == "personal_exams" || currentTable.Name == "personal_license")
+            {
+                int driverId = UserSession.CurrentUser != null && UserSession.CurrentUser.DriverId.HasValue
+                    ? UserSession.CurrentUser.DriverId.Value
+                    : -1;
+                command.Parameters.AddWithValue("@current_driver_id", driverId);
+            }
+        }
+
+        private string GetSelectSql(TableInfo table)
+        {
+            if (table.Name == "personal_profile")
+            {
+                if (GetCurrentDriverCondition() == null)
+                    return "select 'Учётная запись не привязана к водителю. Обратитесь к администратору.' as message";
+
+                return @"select d.driver_id,
+                                concat_ws(' ', d.last_name, d.first_name, d.middle_name) as driver_display,
+                                d.birth_date,
+                                d.phone,
+                                ds.school_name
+                         from akulova_vs.drivers d
+                         join akulova_vs.driving_schools ds on ds.school_id = d.school_id
+                         where d.driver_id = @current_driver_id";
+            }
+
+            if (table.Name == "personal_exams")
+            {
+                if (GetCurrentDriverCondition() == null)
+                    return "select 'Учётная запись не привязана к водителю. Обратитесь к администратору.' as message";
+
+                return @"select exam_id,
+                                exam_date,
+                                result,
+                                route_name,
+                                inspector_full_name,
+                                attempt_number,
+                                driver_age
+                         from (
+                             select e.exam_id,
+                                    e.exam_date,
+                                    e.result,
+                                    r.route_name,
+                                    concat_ws(' ', ti.last_name, ti.first_name, ti.middle_name) as inspector_full_name,
+                                    count(*) over (
+                                        partition by e.driver_id
+                                        order by e.exam_date, e.exam_id
+                                        rows between unbounded preceding and current row
+                                    )::integer as attempt_number,
+                                    extract(year from age(e.exam_date, d.birth_date))::integer as driver_age
+                             from akulova_vs.exams e
+                             join akulova_vs.drivers d on d.driver_id = e.driver_id
+                             join akulova_vs.exam_routes r on r.route_id = e.route_id
+                             join akulova_vs.traffic_inspectors ti on ti.inspector_id = e.inspector_id
+                             where e.driver_id = @current_driver_id
+                         ) personal_exams
+                         order by exam_date desc, exam_id desc";
+            }
+
+            if (table.Name == "personal_license")
+            {
+                if (GetCurrentDriverCondition() == null)
+                    return "select 'Учётная запись не привязана к водителю. Обратитесь к администратору.' as message";
+
+                return @"select l.license_id,
+                                l.issue_date,
+                                l.category,
+                                l.license_number,
+                                concat_ws(' ', o.last_name, o.first_name, o.middle_name) as officer_display
+                         from akulova_vs.licenses l
+                         join akulova_vs.traffic_officers o on o.officer_id = l.officer_id
+                         where l.driver_id = @current_driver_id
+                         order by l.issue_date desc, l.license_id desc";
+            }
+
+            if (table.Name == "driving_schools")
+            {
+                return @"select school_id, school_name, address, phone
+                         from akulova_vs.driving_schools
+                         order by school_id";
+            }
+
+            if (table.Name == "instructors")
+            {
+                return @"select i.instructor_id, i.school_id, ds.school_name as school_display,
+                                i.last_name, i.first_name, i.middle_name, i.phone, i.license_number
+                         from akulova_vs.instructors i
+                         join akulova_vs.driving_schools ds on ds.school_id = i.school_id
+                         order by i.instructor_id";
+            }
+
+            if (table.Name == "drivers")
+            {
+                return @"select d.driver_id, d.school_id, ds.school_name as school_display,
+                                d.last_name, d.first_name, d.middle_name, d.birth_date, d.phone
+                         from akulova_vs.drivers d
+                         join akulova_vs.driving_schools ds on ds.school_id = d.school_id
+                         order by d.driver_id";
+            }
+
+            if (table.Name == "exam_routes")
+            {
+                return @"select route_id, route_name, start_address, end_address, difficulty
+                         from akulova_vs.exam_routes
+                         order by route_id";
+            }
+
+            if (table.Name == "traffic_inspectors")
+            {
+                return @"select inspector_id, last_name, first_name, middle_name, rank_name
+                         from akulova_vs.traffic_inspectors
+                         order by inspector_id";
+            }
+
+            if (table.Name == "traffic_officers")
+            {
+                return @"select officer_id, last_name, first_name, middle_name, position_name
+                         from akulova_vs.traffic_officers
+                         order by officer_id";
+            }
+
+            if (table.Name == "exams")
+            {
+                return @"select e.exam_id,
+                                e.driver_id, concat_ws(' ', d.last_name, d.first_name, d.middle_name) as driver_display,
+                                e.route_id, r.route_name as route_display,
+                                e.inspector_id, concat_ws(' ', ti.last_name, ti.first_name, ti.middle_name) as inspector_display,
+                                e.exam_date, e.result
+                         from akulova_vs.exams e
+                         join akulova_vs.drivers d on d.driver_id = e.driver_id
+                         join akulova_vs.exam_routes r on r.route_id = e.route_id
+                         join akulova_vs.traffic_inspectors ti on ti.inspector_id = e.inspector_id
+                         order by e.exam_id";
+            }
+
+            if (table.Name == "licenses")
+            {
+                return @"select l.license_id,
+                                l.driver_id, concat_ws(' ', d.last_name, d.first_name, d.middle_name) as driver_display,
+                                l.officer_id, concat_ws(' ', o.last_name, o.first_name, o.middle_name) as officer_display,
+                                l.issue_date, l.category, l.license_number
+                         from akulova_vs.licenses l
+                         join akulova_vs.drivers d on d.driver_id = l.driver_id
+                         join akulova_vs.traffic_officers o on o.officer_id = l.officer_id
+                         order by l.license_id";
+            }
+
+            if (table.Name == "app_users")
+            {
+                return @"select u.user_id, u.login, u.role::text as role, u.is_active,
+                                u.instructor_id, concat_ws(' ', i.last_name, i.first_name, i.middle_name) as instructor_display,
+                                u.driver_id, concat_ws(' ', d.last_name, d.first_name, d.middle_name) as driver_display
+                         from akulova_vs.app_users u
+                         left join akulova_vs.instructors i on i.instructor_id = u.instructor_id
+                         left join akulova_vs.drivers d on d.driver_id = u.driver_id
+                         order by u.user_id";
+            }
+
+            if (table.Name == "audit_log")
+            {
+                return @"select log_id,
+                                table_name,
+                                operation,
+                                record_id,
+                                changed_at,
+                                coalesce(nullif(changed_by, ''), 'system') as changed_by
+                         from akulova_vs.audit_log
+                         order by changed_at desc, log_id desc
+                         limit 500";
+            }
+
+            return "select * from " + FullName(table.Name) + " order by " + Quote(table.PrimaryKey);
+        }
+
+        private string GetCurrentDriverCondition()
+        {
+            if (UserSession.CurrentUser == null || !UserSession.CurrentUser.DriverId.HasValue)
+                return null;
+
+            return UserSession.CurrentUser.DriverId.Value.ToString();
+        }
+
+        private void ApplyGridPresentation(DataGridView grid)
+        {
+            if (grid == null)
+                return;
+
+            foreach (DataGridViewColumn column in grid.Columns)
+            {
+                string name = column.Name;
+                column.Visible = !IsTechnicalColumn(name);
+                column.HeaderText = GetColumnCaption(name);
+
+                if (name.EndsWith("_date", StringComparison.OrdinalIgnoreCase) || name == "birth_date" || name == "exam_date" || name == "issue_date")
+                    column.DefaultCellStyle.Format = "dd.MM.yyyy";
+            }
+
+            grid.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill;
+            grid.AutoResizeColumns();
+        }
+
+        private bool IsTechnicalColumn(string columnName)
+        {
+            if (string.IsNullOrEmpty(columnName))
+                return false;
+
+            if (columnName == "password_hash")
+                return true;
+
+            if (columnName == "record_id")
+                return false;
+
+            if (columnName.EndsWith("_id", StringComparison.OrdinalIgnoreCase))
+                return true;
+
+            return false;
+        }
+
+        private string GetColumnCaption(string columnName)
+        {
+            Dictionary<string, string> captions = new Dictionary<string, string>
+            {
+                { "school_name", "Название автошколы" },
+                { "address", "Адрес" },
+                { "phone", "Телефон" },
+                { "school_display", "Автошкола" },
+                { "last_name", "Фамилия" },
+                { "first_name", "Имя" },
+                { "middle_name", "Отчество" },
+                { "license_number", "Номер удостоверения" },
+                { "birth_date", "Дата рождения" },
+                { "route_name", "Название маршрута" },
+                { "route_display", "Маршрут" },
+                { "start_address", "Начальный адрес" },
+                { "end_address", "Конечный адрес" },
+                { "difficulty", "Сложность" },
+                { "rank_name", "Звание" },
+                { "position_name", "Должность" },
+                { "driver_display", "Водитель" },
+                { "message", "Сообщение" },
+                { "inspector_display", "Инспектор" },
+                { "officer_display", "Сотрудник" },
+                { "exam_date", "Дата экзамена" },
+                { "result", "Результат" },
+                { "issue_date", "Дата выдачи" },
+                { "category", "Категория" },
+                { "login", "Логин" },
+                { "role", "Роль" },
+                { "is_active", "Активен" },
+                { "instructor_display", "Инструктор" },
+                { "driver_full_name", "Водитель" },
+                { "inspector_full_name", "Инспектор" },
+                { "attempt_number", "Попытка №" },
+                { "driver_age", "Возраст водителя" },
+                { "table_name", "Таблица" },
+                { "operation", "Операция" },
+                { "record_id", "ID записи" },
+                { "changed_at", "Дата и время" },
+                { "changed_by", "Пользователь" }
+            };
+
+            return captions.ContainsKey(columnName) ? captions[columnName] : columnName;
+        }
+
+        private void BtnRefresh_Click(object sender, EventArgs e)
+        {
+            LoadCurrentTable();
+        }
+
+        private void BtnAdd_Click(object sender, EventArgs e)
+        {
+            if (!UserSession.IsAdmin)
+            {
+                ShowReadonlyMessage();
+                return;
+            }
+
+            if (currentTable == null)
+                return;
+
+            if (currentTable.IsReadOnly)
+            {
+                MessageBox.Show("Этот раздел предназначен только для просмотра.", "Информация",
+                    MessageBoxButtons.OK, MessageBoxIcon.Information);
+                return;
+            }
+
+            using (RecordEditForm form = new RecordEditForm(DbSettings.ConnectionString, currentTable, null))
+            {
+                if (form.ShowDialog(this) == DialogResult.OK)
+                {
+                    LoadCurrentTable();
+                    LoadExamSearch();
+                }
+            }
+        }
+
+        private void BtnEdit_Click(object sender, EventArgs e)
+        {
+            if (!UserSession.IsAdmin)
+            {
+                ShowReadonlyMessage();
+                return;
+            }
+
+            if (currentTable != null && currentTable.IsReadOnly)
+            {
+                MessageBox.Show("Этот раздел предназначен только для просмотра.", "Информация",
+                    MessageBoxButtons.OK, MessageBoxIcon.Information);
+                return;
+            }
+
+            if (currentTable == null || dgvTables.SelectedRows.Count == 0)
+            {
+                MessageBox.Show("Выберите запись для изменения.", "Информация",
+                    MessageBoxButtons.OK, MessageBoxIcon.Information);
+                return;
+            }
+
+            DataRowView rowView = dgvTables.SelectedRows[0].DataBoundItem as DataRowView;
+            if (rowView == null)
+                return;
+
+            using (RecordEditForm form = new RecordEditForm(DbSettings.ConnectionString, currentTable, rowView.Row))
+            {
+                if (form.ShowDialog(this) == DialogResult.OK)
+                {
+                    LoadCurrentTable();
+                    LoadExamSearch();
+                }
+            }
+        }
+
+        private void BtnDelete_Click(object sender, EventArgs e)
+        {
+            if (!UserSession.IsAdmin)
+            {
+                ShowReadonlyMessage();
+                return;
+            }
+
+            if (currentTable != null && currentTable.IsReadOnly)
+            {
+                MessageBox.Show("Этот раздел предназначен только для просмотра.", "Информация",
+                    MessageBoxButtons.OK, MessageBoxIcon.Information);
+                return;
+            }
+
+            if (currentTable == null || dgvTables.SelectedRows.Count == 0)
+            {
+                MessageBox.Show("Выберите запись для удаления.", "Информация",
+                    MessageBoxButtons.OK, MessageBoxIcon.Information);
+                return;
+            }
+
+            DataRowView rowView = dgvTables.SelectedRows[0].DataBoundItem as DataRowView;
+            if (rowView == null)
+                return;
+
+            object pkValue = rowView.Row[currentTable.PrimaryKey];
+
+            if (currentTable.Name == "app_users" && Convert.ToInt32(pkValue) == UserSession.CurrentUser.UserId)
+            {
+                MessageBox.Show("Нельзя удалить текущую учётную запись, под которой выполнен вход.",
+                    "Удаление запрещено", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+                return;
+            }
+
+            DialogResult result = MessageBox.Show("Удалить выбранную запись?",
+                "Подтверждение", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
+
+            if (result != DialogResult.Yes)
+                return;
+
+            try
+            {
+                using (NpgsqlConnection connection = new NpgsqlConnection(DbSettings.ConnectionString))
+                {
+                    connection.Open();
+                    DbAppContext.Apply(connection);
+                    string sql = "delete from " + FullName(currentTable.Name) + " where " + Quote(currentTable.PrimaryKey) + " = @id";
+                    using (NpgsqlCommand command = new NpgsqlCommand(sql, connection))
+                    {
+                        command.Parameters.AddWithValue("@id", pkValue);
+                        command.ExecuteNonQuery();
+                        DbAppContext.MarkLatestAuditLogin(connection, currentTable.Name, pkValue);
+                    }
+                }
+
+                LoadCurrentTable();
+                LoadExamSearch();
+            }
+            catch (Exception ex)
+            {
+                MessageBox.Show(AppError.ToUserMessage(ex, "Не удалось удалить запись."),
+                    "Ошибка удаления", MessageBoxButtons.OK, MessageBoxIcon.Error);
+            }
+        }
+
+        private void ShowReadonlyMessage()
+        {
+            MessageBox.Show("Обычный пользователь работает только с личным кабинетом. Изменение данных доступно только роли admin.",
+                "Недостаточно прав", MessageBoxButtons.OK, MessageBoxIcon.Information);
+        }
+
+        private void BtnExportTable_Click(object sender, EventArgs e)
+        {
+            ExportTable(dgvTables, currentTable == null ? "table" : currentTable.Name);
+        }
+
+        private void BtnSearch_Click(object sender, EventArgs e)
+        {
+            LoadExamSearch();
+        }
+
+        private void BtnReset_Click(object sender, EventArgs e)
+        {
+            txtDriverFilter.Clear();
+            txtSchoolFilter.Clear();
+            cmbResultFilter.SelectedIndex = 0;
+            dtFrom.Checked = false;
+            dtTo.Checked = false;
+            LoadExamSearch();
+        }
+
+        private void BtnExportSearch_Click(object sender, EventArgs e)
+        {
+            ExportTable(dgvSearch, "exam_search");
+        }
+
+        private void LoadExamSearch()
+        {
+            try
+            {
+                using (NpgsqlConnection connection = new NpgsqlConnection(DbSettings.ConnectionString))
+                {
+                    connection.Open();
+
+                    const string sql = @"
+                        select exam_id, exam_date, result, driver_full_name, school_name, route_name, inspector_full_name, attempt_number, driver_age
+                        from akulova_vs.v_exam_search
+                        where (cast(@driver as text) = '' or driver_full_name ilike '%' || cast(@driver as text) || '%')
+                          and (cast(@school as text) = '' or school_name ilike '%' || cast(@school as text) || '%')
+                          and (cast(@result as text) = '' or result = cast(@result as text))
+                          and (cast(@date_from as date) is null or exam_date >= cast(@date_from as date))
+                          and (cast(@date_to as date) is null or exam_date <= cast(@date_to as date))
+                        order by exam_date desc, exam_id desc;";
+
+                    using (NpgsqlCommand command = new NpgsqlCommand(sql, connection))
+                    {
+                        command.Parameters.AddWithValue("@driver", txtDriverFilter == null ? "" : txtDriverFilter.Text.Trim());
+                        command.Parameters.AddWithValue("@school", txtSchoolFilter == null ? "" : txtSchoolFilter.Text.Trim());
+                        string selectedResult = cmbResultFilter == null || cmbResultFilter.SelectedIndex <= 0
+                            ? ""
+                            : cmbResultFilter.SelectedItem.ToString();
+                        command.Parameters.AddWithValue("@result", selectedResult);
+
+                        Npgsql.NpgsqlParameter dateFromParameter = command.Parameters.Add("@date_from", NpgsqlTypes.NpgsqlDbType.Date);
+                        dateFromParameter.Value = dtFrom != null && dtFrom.Checked ? (object)dtFrom.Value.Date : DBNull.Value;
+
+                        Npgsql.NpgsqlParameter dateToParameter = command.Parameters.Add("@date_to", NpgsqlTypes.NpgsqlDbType.Date);
+                        dateToParameter.Value = dtTo != null && dtTo.Checked ? (object)dtTo.Value.Date : DBNull.Value;
+
+                        using (NpgsqlDataAdapter adapter = new NpgsqlDataAdapter(command))
+                        {
+                            currentSearchData = new DataTable();
+                            adapter.Fill(currentSearchData);
+                            if (dgvSearch != null)
+                            {
+                                dgvSearch.DataSource = currentSearchData;
+                                ApplyGridPresentation(dgvSearch);
+                            }
+                        }
+                    }
+                }
+            }
+            catch (Exception ex)
+            {
+                if (dgvSearch != null)
+                {
+                    MessageBox.Show(AppError.ToUserMessage(ex, "Не удалось выполнить поиск по экзаменам."),
+                        "Ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error);
+                }
+            }
+        }
+
+        private void ExportTable(DataGridView grid, string filePrefix)
+        {
+            if (grid == null || grid.DataSource == null || grid.Rows.Count == 0)
+            {
+                MessageBox.Show("Нет данных для экспорта.", "Информация",
+                    MessageBoxButtons.OK, MessageBoxIcon.Information);
+                return;
+            }
+
+            using (SaveFileDialog dialog = new SaveFileDialog())
+            {
+                dialog.Filter = "CSV файл (*.csv)|*.csv";
+                dialog.FileName = filePrefix + "_" + DateTime.Now.ToString("yyyyMMdd_HHmm") + ".csv";
+
+                if (dialog.ShowDialog(this) != DialogResult.OK)
+                    return;
+
+                try
+                {
+                    WriteCsv(grid, dialog.FileName);
+                    MessageBox.Show("CSV-файл сохранён:\n" + dialog.FileName,
+                        "Экспорт выполнен", MessageBoxButtons.OK, MessageBoxIcon.Information);
+                }
+                catch (Exception ex)
+                {
+                    MessageBox.Show("Не удалось сохранить CSV.\n\n" + ex.Message,
+                        "Ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error);
+                }
+            }
+        }
+
+        private void WriteCsv(DataGridView grid, string path)
+        {
+            List<DataGridViewColumn> visibleColumns = new List<DataGridViewColumn>();
+            foreach (DataGridViewColumn column in grid.Columns)
+            {
+                if (column.Visible)
+                    visibleColumns.Add(column);
+            }
+
+            using (StreamWriter writer = new StreamWriter(path, false, new UTF8Encoding(true)))
+            {
+                for (int i = 0; i < visibleColumns.Count; i++)
+                {
+                    if (i > 0) writer.Write(';');
+                    writer.Write(EscapeCsv(visibleColumns[i].HeaderText));
+                }
+                writer.WriteLine();
+
+                foreach (DataGridViewRow row in grid.Rows)
+                {
+                    if (row.IsNewRow)
+                        continue;
+
+                    for (int i = 0; i < visibleColumns.Count; i++)
+                    {
+                        if (i > 0) writer.Write(';');
+                        object value = row.Cells[visibleColumns[i].Name].Value;
+                        writer.Write(EscapeCsv(value == null || value == DBNull.Value ? "" : value.ToString()));
+                    }
+                    writer.WriteLine();
+                }
+            }
+        }
+
+        private string EscapeCsv(string value)
+        {
+            if (value == null) return "";
+            bool needQuotes = value.Contains(";") || value.Contains("\"") || value.Contains("\n") || value.Contains("\r");
+            value = value.Replace("\"", "\"\"");
+            return needQuotes ? "\"" + value + "\"" : value;
+        }
+
+        private void BtnLogout_Click(object sender, EventArgs e)
+        {
+            UserSession.Logout();
+            Close();
+        }
+
+        public static string FullName(string identifier)
+        {
+            return Quote(DbSchema) + "." + Quote(identifier);
+        }
+
+        public static string Quote(string identifier)
+        {
+            return "\"" + identifier.Replace("\"", "\"\"") + "\"";
+        }
+    }
+
+    public class TableInfo
+    {
+        public string Name { get; private set; }
+        public string DisplayName { get; private set; }
+        public string PrimaryKey { get; private set; }
+        public bool IsReadOnly { get; private set; }
+
+        public TableInfo(string name, string displayName, string primaryKey)
+            : this(name, displayName, primaryKey, false)
+        {
+        }
+
+        public TableInfo(string name, string displayName, string primaryKey, bool isReadOnly)
+        {
+            Name = name;
+            DisplayName = displayName;
+            PrimaryKey = primaryKey;
+            IsReadOnly = isReadOnly;
+        }
+
+        public override string ToString()
+        {
+            return DisplayName;
+        }
+    }
+
+    public class ColumnInfo
+    {
+        public string Name { get; set; }
+        public string DataType { get; set; }
+        public string UdtName { get; set; }
+        public bool IsNullable { get; set; }
+        public bool IsIdentity { get; set; }
+        public bool IsPrimaryKey { get; set; }
+    }
+
+    public class ComboItem
+    {
+        public int? Id { get; private set; }
+        public string Text { get; private set; }
+
+        public ComboItem(int? id, string text)
+        {
+            Id = id;
+            Text = text;
+        }
+
+        public override string ToString()
+        {
+            return Text;
+        }
+    }
+
+    public class FieldBinding
+    {
+        public ColumnInfo Column { get; set; }
+        public Control Control { get; set; }
+        public bool IsPasswordField { get; set; }
+    }
+}

+ 120 - 0
Autoschool_PM11/autoschool/WindowsFormsApp6/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>

+ 16 - 0
Autoschool_PM11/autoschool/WindowsFormsApp6/Program.cs

@@ -0,0 +1,16 @@
+using System;
+using System.Windows.Forms;
+
+namespace SimpleDBViewer
+{
+    internal static class Program
+    {
+        [STAThread]
+        static void Main()
+        {
+            Application.EnableVisualStyles();
+            Application.SetCompatibleTextRenderingDefault(false);
+            Application.Run(new LoginForm());
+        }
+    }
+}

+ 36 - 0
Autoschool_PM11/autoschool/WindowsFormsApp6/Properties/AssemblyInfo.cs

@@ -0,0 +1,36 @@
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+// Общие сведения об этой сборке предоставляются следующим набором
+// набора атрибутов. Измените значения этих атрибутов для изменения сведений,
+// связанных со сборкой.
+[assembly: AssemblyTitle("WindowsFormsApp6")]
+[assembly: AssemblyDescription("")]
+[assembly: AssemblyConfiguration("")]
+[assembly: AssemblyCompany("")]
+[assembly: AssemblyProduct("WindowsFormsApp6")]
+[assembly: AssemblyCopyright("Copyright ©  2025")]
+[assembly: AssemblyTrademark("")]
+[assembly: AssemblyCulture("")]
+
+// Установка значения False для параметра ComVisible делает типы в этой сборке невидимыми
+// для компонентов COM. Если необходимо обратиться к типу в этой сборке через
+// COM, следует установить атрибут ComVisible в TRUE для этого типа.
+[assembly: ComVisible(false)]
+
+// Следующий GUID служит для идентификации библиотеки типов, если этот проект будет видимым для COM
+[assembly: Guid("a3e8a077-88c9-48f0-a591-af6ad5fc350c")]
+
+// Сведения о версии сборки состоят из указанных ниже четырех значений:
+//
+//      Основной номер версии
+//      Дополнительный номер версии
+//      Номер сборки
+//      Редакция
+//
+// Можно задать все значения или принять номера сборки и редакции по умолчанию 
+// используя "*", как показано ниже:
+// [assembly: AssemblyVersion("1.0.*")]
+[assembly: AssemblyVersion("1.0.0.0")]
+[assembly: AssemblyFileVersion("1.0.0.0")]

+ 71 - 0
Autoschool_PM11/autoschool/WindowsFormsApp6/Properties/Resources.Designer.cs

@@ -0,0 +1,71 @@
+//------------------------------------------------------------------------------
+// <auto-generated>
+//     Этот код создан программным средством.
+//     Версия среды выполнения: 4.0.30319.42000
+//
+//     Изменения в этом файле могут привести к неправильному поведению и будут утрачены, если
+//     код создан повторно.
+// </auto-generated>
+//------------------------------------------------------------------------------
+
+namespace WindowsFormsApp6.Properties
+{
+
+
+    /// <summary>
+    ///   Класс ресурсов со строгим типом для поиска локализованных строк и пр.
+    /// </summary>
+    // Этот класс был автоматически создан при помощи StronglyTypedResourceBuilder
+    // класс с помощью таких средств, как ResGen или Visual Studio.
+    // Для добавления или удаления члена измените файл .ResX, а затем перезапустите ResGen
+    // с параметром /str или заново постройте свой VS-проект.
+    [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+    [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+    internal class Resources
+    {
+
+        private static global::System.Resources.ResourceManager resourceMan;
+
+        private static global::System.Globalization.CultureInfo resourceCulture;
+
+        [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
+        internal Resources()
+        {
+        }
+
+        /// <summary>
+        ///   Возврат кэшированного экземпляра ResourceManager, используемого этим классом.
+        /// </summary>
+        [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+        internal static global::System.Resources.ResourceManager ResourceManager
+        {
+            get
+            {
+                if ((resourceMan == null))
+                {
+                    global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("WindowsFormsApp6.Properties.Resources", typeof(Resources).Assembly);
+                    resourceMan = temp;
+                }
+                return resourceMan;
+            }
+        }
+
+        /// <summary>
+        ///   Переопределяет свойство CurrentUICulture текущего потока для всех
+        ///   подстановки ресурсов с помощью этого класса ресурсов со строгим типом.
+        /// </summary>
+        [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+        internal static global::System.Globalization.CultureInfo Culture
+        {
+            get
+            {
+                return resourceCulture;
+            }
+            set
+            {
+                resourceCulture = value;
+            }
+        }
+    }
+}

+ 117 - 0
Autoschool_PM11/autoschool/WindowsFormsApp6/Properties/Resources.resx

@@ -0,0 +1,117 @@
+<?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.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: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" type="xsd:string" />
+              <xsd:attribute name="type" type="xsd:string" />
+              <xsd:attribute name="mimetype" type="xsd:string" />
+            </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" msdata:Ordinal="1" />
+              <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
+              <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
+            </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=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <resheader name="writer">
+    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+</root>

+ 30 - 0
Autoschool_PM11/autoschool/WindowsFormsApp6/Properties/Settings.Designer.cs

@@ -0,0 +1,30 @@
+//------------------------------------------------------------------------------
+// <auto-generated>
+//     This code was generated by a tool.
+//     Runtime Version:4.0.30319.42000
+//
+//     Changes to this file may cause incorrect behavior and will be lost if
+//     the code is regenerated.
+// </auto-generated>
+//------------------------------------------------------------------------------
+
+namespace WindowsFormsApp6.Properties
+{
+
+
+    [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+    [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "11.0.0.0")]
+    internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase
+    {
+
+        private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings())));
+
+        public static Settings Default
+        {
+            get
+            {
+                return defaultInstance;
+            }
+        }
+    }
+}

+ 7 - 0
Autoschool_PM11/autoschool/WindowsFormsApp6/Properties/Settings.settings

@@ -0,0 +1,7 @@
+<?xml version='1.0' encoding='utf-8'?>
+<SettingsFile xmlns="http://schemas.microsoft.com/VisualStudio/2004/01/settings" CurrentProfile="(Default)">
+  <Profiles>
+    <Profile Name="(Default)" />
+  </Profiles>
+  <Settings />
+</SettingsFile>

+ 124 - 0
Autoschool_PM11/autoschool/WindowsFormsApp6/RecordEditForm.Designer.cs

@@ -0,0 +1,124 @@
+namespace SimpleDBViewer
+{
+    partial class RecordEditForm
+    {
+        private System.ComponentModel.IContainer components = null;
+        private System.Windows.Forms.Label lblTitle;
+        private System.Windows.Forms.Panel pnlFooter;
+        private System.Windows.Forms.Button btnSave;
+        private System.Windows.Forms.Button btnCancel;
+        private System.Windows.Forms.FlowLayoutPanel fieldsPanel;
+
+        protected override void Dispose(bool disposing)
+        {
+            if (disposing && (components != null))
+            {
+                components.Dispose();
+            }
+            base.Dispose(disposing);
+        }
+
+        private void InitializeComponent()
+        {
+            this.lblTitle = new System.Windows.Forms.Label();
+            this.pnlFooter = new System.Windows.Forms.Panel();
+            this.btnCancel = new System.Windows.Forms.Button();
+            this.btnSave = new System.Windows.Forms.Button();
+            this.fieldsPanel = new System.Windows.Forms.FlowLayoutPanel();
+            this.pnlFooter.SuspendLayout();
+            this.SuspendLayout();
+            // 
+            // lblTitle
+            // 
+            this.lblTitle.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(30)))), ((int)(((byte)(58)))), ((int)(((byte)(84)))));
+            this.lblTitle.Dock = System.Windows.Forms.DockStyle.Top;
+            this.lblTitle.Font = new System.Drawing.Font("Segoe UI", 14F, System.Drawing.FontStyle.Bold);
+            this.lblTitle.ForeColor = System.Drawing.Color.White;
+            this.lblTitle.Location = new System.Drawing.Point(0, 0);
+            this.lblTitle.Name = "lblTitle";
+            this.lblTitle.Padding = new System.Windows.Forms.Padding(22, 0, 0, 0);
+            this.lblTitle.Size = new System.Drawing.Size(520, 56);
+            this.lblTitle.TabIndex = 0;
+            this.lblTitle.Text = "Запись";
+            this.lblTitle.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
+            // 
+            // pnlFooter
+            // 
+            this.pnlFooter.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(248)))), ((int)(((byte)(250)))), ((int)(((byte)(252)))));
+            this.pnlFooter.Controls.Add(this.btnCancel);
+            this.pnlFooter.Controls.Add(this.btnSave);
+            this.pnlFooter.Dock = System.Windows.Forms.DockStyle.Bottom;
+            this.pnlFooter.Location = new System.Drawing.Point(0, 548);
+            this.pnlFooter.Name = "pnlFooter";
+            this.pnlFooter.Size = new System.Drawing.Size(520, 72);
+            this.pnlFooter.TabIndex = 2;
+            // 
+            // btnCancel
+            // 
+            this.btnCancel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right)));
+            this.btnCancel.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(226)))), ((int)(((byte)(232)))), ((int)(((byte)(240)))));
+            this.btnCancel.DialogResult = System.Windows.Forms.DialogResult.Cancel;
+            this.btnCancel.FlatAppearance.BorderSize = 0;
+            this.btnCancel.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
+            this.btnCancel.Font = new System.Drawing.Font("Segoe UI", 9.5F, System.Drawing.FontStyle.Bold);
+            this.btnCancel.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(51)))), ((int)(((byte)(65)))), ((int)(((byte)(85)))));
+            this.btnCancel.Location = new System.Drawing.Point(372, 17);
+            this.btnCancel.Name = "btnCancel";
+            this.btnCancel.Size = new System.Drawing.Size(124, 38);
+            this.btnCancel.TabIndex = 1;
+            this.btnCancel.Text = "Отмена";
+            this.btnCancel.UseVisualStyleBackColor = false;
+            this.btnCancel.Click += new System.EventHandler(this.BtnCancel_Click);
+            // 
+            // btnSave
+            // 
+            this.btnSave.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right)));
+            this.btnSave.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(37)))), ((int)(((byte)(99)))), ((int)(((byte)(235)))));
+            this.btnSave.FlatAppearance.BorderSize = 0;
+            this.btnSave.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
+            this.btnSave.Font = new System.Drawing.Font("Segoe UI", 9.5F, System.Drawing.FontStyle.Bold);
+            this.btnSave.ForeColor = System.Drawing.Color.White;
+            this.btnSave.Location = new System.Drawing.Point(238, 17);
+            this.btnSave.Name = "btnSave";
+            this.btnSave.Size = new System.Drawing.Size(124, 38);
+            this.btnSave.TabIndex = 0;
+            this.btnSave.Text = "Сохранить";
+            this.btnSave.UseVisualStyleBackColor = false;
+            this.btnSave.Click += new System.EventHandler(this.BtnSave_Click);
+            // 
+            // fieldsPanel
+            // 
+            this.fieldsPanel.AutoScroll = true;
+            this.fieldsPanel.BackColor = System.Drawing.Color.White;
+            this.fieldsPanel.Dock = System.Windows.Forms.DockStyle.Fill;
+            this.fieldsPanel.FlowDirection = System.Windows.Forms.FlowDirection.TopDown;
+            this.fieldsPanel.Location = new System.Drawing.Point(0, 56);
+            this.fieldsPanel.Name = "fieldsPanel";
+            this.fieldsPanel.Padding = new System.Windows.Forms.Padding(22);
+            this.fieldsPanel.Size = new System.Drawing.Size(520, 492);
+            this.fieldsPanel.TabIndex = 1;
+            this.fieldsPanel.WrapContents = false;
+            this.fieldsPanel.Paint += new System.Windows.Forms.PaintEventHandler(this.fieldsPanel_Paint);
+            // 
+            // RecordEditForm
+            // 
+            this.AcceptButton = this.btnSave;
+            this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 17F);
+            this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
+            this.BackColor = System.Drawing.Color.White;
+            this.CancelButton = this.btnCancel;
+            this.ClientSize = new System.Drawing.Size(520, 620);
+            this.Controls.Add(this.fieldsPanel);
+            this.Controls.Add(this.pnlFooter);
+            this.Controls.Add(this.lblTitle);
+            this.Font = new System.Drawing.Font("Segoe UI", 9.5F);
+            this.MinimumSize = new System.Drawing.Size(480, 520);
+            this.Name = "RecordEditForm";
+            this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
+            this.Text = "Запись";
+            this.pnlFooter.ResumeLayout(false);
+            this.ResumeLayout(false);
+
+        }
+    }
+}

+ 775 - 0
Autoschool_PM11/autoschool/WindowsFormsApp6/RecordEditForm.cs

@@ -0,0 +1,775 @@
+using System;
+using System.Collections.Generic;
+using System.Data;
+using System.Drawing;
+using System.Text.RegularExpressions;
+using System.Windows.Forms;
+using Npgsql;
+
+namespace SimpleDBViewer
+{
+
+
+    public partial class RecordEditForm : Form
+    {
+        private readonly string connectionString;
+        private readonly TableInfo table;
+        private readonly DataRow row;
+        private readonly bool isEdit;
+        private readonly List<FieldBinding> bindings = new List<FieldBinding>();
+
+        public RecordEditForm()
+        {
+            connectionString = string.Empty;
+            table = null;
+            row = null;
+            isEdit = false;
+            InitializeComponent();
+        }
+
+        public RecordEditForm(string connectionString, TableInfo table, DataRow row)
+        {
+            this.connectionString = connectionString;
+            this.table = table;
+            this.row = row;
+            isEdit = row != null;
+
+            InitializeComponent();
+            PrepareWindow();
+            LoadFields();
+        }
+        private void PrepareWindow()
+        {
+            Text = (isEdit ? "Изменить: " : "Добавить: ") + table.DisplayName;
+            lblTitle.Text = table.DisplayName;
+        }
+
+        private void BtnCancel_Click(object sender, EventArgs e)
+        {
+            DialogResult = DialogResult.Cancel;
+            Close();
+        }
+
+        private void LoadFields()
+        {
+            List<ColumnInfo> columns = GetColumns();
+
+            foreach (ColumnInfo column in columns)
+            {
+                if (string.Equals(column.Name, table.PrimaryKey, StringComparison.OrdinalIgnoreCase))
+                    continue;
+
+                if (table.Name == "app_users" && column.Name == "password_hash")
+                {
+                    AddPasswordField();
+                    continue;
+                }
+
+                AddColumnField(column);
+            }
+        }
+
+        private List<ColumnInfo> GetColumns()
+        {
+            List<ColumnInfo> result = new List<ColumnInfo>();
+            using (NpgsqlConnection connection = new NpgsqlConnection(connectionString))
+            {
+                connection.Open();
+                const string sql = @"
+                    select c.column_name,
+                           c.data_type,
+                           c.udt_name,
+                           c.is_nullable,
+                           c.is_identity,
+                           exists (
+                               select 1
+                               from pg_index i
+                               join pg_attribute a on a.attrelid = i.indrelid and a.attnum = any(i.indkey)
+                               where i.indrelid = to_regclass(@qualified_table_name)
+                                 and i.indisprimary
+                                 and a.attname = c.column_name
+                           ) as is_primary_key
+                    from information_schema.columns c
+                    where c.table_schema = @table_schema
+                      and c.table_name = @table_name
+                    order by c.ordinal_position;";
+
+                using (NpgsqlCommand command = new NpgsqlCommand(sql, connection))
+                {
+                    command.Parameters.AddWithValue("@table_schema", MainForm.DbSchema);
+                    command.Parameters.AddWithValue("@table_name", table.Name);
+                    command.Parameters.AddWithValue("@qualified_table_name", MainForm.FullName(table.Name));
+                    using (NpgsqlDataReader reader = command.ExecuteReader())
+                    {
+                        while (reader.Read())
+                        {
+                            result.Add(new ColumnInfo
+                            {
+                                Name = reader["column_name"].ToString(),
+                                DataType = reader["data_type"].ToString(),
+                                UdtName = reader["udt_name"].ToString(),
+                                IsNullable = reader["is_nullable"].ToString() == "YES",
+                                IsIdentity = reader["is_identity"].ToString() == "YES",
+                                IsPrimaryKey = Convert.ToBoolean(reader["is_primary_key"])
+                            });
+                        }
+                    }
+                }
+            }
+            return result;
+        }
+
+        private void AddPasswordField()
+        {
+            Panel rowPanel = CreateFieldPanel("Пароль" + (isEdit ? " (оставьте пустым, чтобы не менять)" : ""));
+            TextBox textBox = new TextBox
+            {
+                Location = new Point(160, 0),
+                Width = 290,
+                PasswordChar = '•',
+                BorderStyle = BorderStyle.FixedSingle
+            };
+            rowPanel.Controls.Add(textBox);
+            fieldsPanel.Controls.Add(rowPanel);
+
+            bindings.Add(new FieldBinding
+            {
+                Column = new ColumnInfo { Name = "password_hash", DataType = "text", UdtName = "text", IsNullable = isEdit },
+                Control = textBox,
+                IsPasswordField = true
+            });
+        }
+
+        private void AddColumnField(ColumnInfo column)
+        {
+            Panel rowPanel = CreateFieldPanel(GetLabelText(column));
+            Control editor = CreateEditor(column);
+            rowPanel.Controls.Add(editor);
+            fieldsPanel.Controls.Add(rowPanel);
+
+            bindings.Add(new FieldBinding
+            {
+                Column = column,
+                Control = editor,
+                IsPasswordField = false
+            });
+        }
+
+        private Panel CreateFieldPanel(string labelText)
+        {
+            Panel panel = new Panel
+            {
+                Width = 455,
+                Height = 58,
+                Margin = new Padding(0, 0, 0, 8)
+            };
+
+            Label label = new Label
+            {
+                Text = labelText,
+                Location = new Point(0, 0),
+                Size = new Size(150, 24),
+                ForeColor = Color.FromArgb(51, 65, 85),
+                TextAlign = ContentAlignment.MiddleLeft
+            };
+            panel.Controls.Add(label);
+            return panel;
+        }
+
+        private string GetLabelText(ColumnInfo column)
+        {
+            Dictionary<string, string> labels = new Dictionary<string, string>
+            {
+                { "school_name", "Название" },
+                { "address", "Адрес" },
+                { "phone", "Телефон" },
+                { "school_id", "Автошкола" },
+                { "last_name", "Фамилия" },
+                { "first_name", "Имя" },
+                { "middle_name", "Отчество" },
+                { "license_number", "Номер удостоверения" },
+                { "birth_date", "Дата рождения" },
+                { "route_name", "Маршрут" },
+                { "start_address", "Начальный адрес" },
+                { "end_address", "Конечный адрес" },
+                { "difficulty", "Сложность" },
+                { "rank_name", "Звание" },
+                { "position_name", "Должность" },
+                { "driver_id", "Водитель" },
+                { "route_id", "Маршрут" },
+                { "inspector_id", "Инспектор" },
+                { "exam_date", "Дата экзамена" },
+                { "result", "Результат" },
+                { "officer_id", "Сотрудник" },
+                { "issue_date", "Дата выдачи" },
+                { "category", "Категория" },
+                { "login", "Логин" },
+                { "role", "Роль" },
+                { "is_active", "Активен" },
+                { "instructor_id", "Инструктор" }
+            };
+
+            return labels.ContainsKey(column.Name) ? labels[column.Name] : column.Name;
+        }
+
+        private Control CreateEditor(ColumnInfo column)
+        {
+            object existingValue = isEdit && row.Table.Columns.Contains(column.Name) ? row[column.Name] : DBNull.Value;
+
+            if (column.UdtName == "bool" || column.DataType == "boolean")
+            {
+                CheckBox checkBox = new CheckBox
+                {
+                    Location = new Point(160, 6),
+                    Width = 290,
+                    Checked = existingValue != DBNull.Value && Convert.ToBoolean(existingValue)
+                };
+                return checkBox;
+            }
+
+            if (column.DataType == "date" || column.UdtName == "date")
+            {
+                DateTimePicker picker = new DateTimePicker
+                {
+                    Location = new Point(160, 0),
+                    Width = 290,
+                    Format = DateTimePickerFormat.Short,
+                    ShowCheckBox = column.IsNullable
+                };
+
+                if (existingValue != DBNull.Value)
+                {
+                    picker.Value = Convert.ToDateTime(existingValue);
+                    picker.Checked = true;
+                }
+                else if (column.IsNullable)
+                {
+                    picker.Checked = false;
+                }
+
+                return picker;
+            }
+
+            List<ComboItem> fkItems = LoadForeignKeyItems(column);
+            if (fkItems != null)
+            {
+                ComboBox comboBox = new ComboBox
+                {
+                    Location = new Point(160, 0),
+                    Width = 290,
+                    DropDownStyle = ComboBoxStyle.DropDownList
+                };
+
+                foreach (ComboItem item in fkItems)
+                    comboBox.Items.Add(item);
+
+                if (existingValue != DBNull.Value)
+                {
+                    int currentId = Convert.ToInt32(existingValue);
+                    for (int i = 0; i < comboBox.Items.Count; i++)
+                    {
+                        ComboItem item = (ComboItem)comboBox.Items[i];
+                        if (item.Id.HasValue && item.Id.Value == currentId)
+                        {
+                            comboBox.SelectedIndex = i;
+                            break;
+                        }
+                    }
+                }
+
+                if (comboBox.SelectedIndex < 0 && comboBox.Items.Count > 0)
+                    comboBox.SelectedIndex = 0;
+
+                return comboBox;
+            }
+
+            List<string> values = GetFixedValues(column);
+            if (values != null)
+            {
+                ComboBox comboBox = new ComboBox
+                {
+                    Location = new Point(160, 0),
+                    Width = 290,
+                    DropDownStyle = ComboBoxStyle.DropDownList
+                };
+
+                foreach (string value in values)
+                    comboBox.Items.Add(value);
+
+                if (existingValue != DBNull.Value)
+                    comboBox.SelectedItem = existingValue.ToString();
+
+                if (comboBox.SelectedIndex < 0 && comboBox.Items.Count > 0)
+                    comboBox.SelectedIndex = 0;
+
+                return comboBox;
+            }
+
+            TextBox textBox = new TextBox
+            {
+                Location = new Point(160, 0),
+                Width = 290,
+                BorderStyle = BorderStyle.FixedSingle,
+                Text = existingValue == DBNull.Value ? "" : existingValue.ToString(),
+                MaxLength = GetMaxLength(column)
+            };
+            return textBox;
+        }
+
+        private List<string> GetFixedValues(ColumnInfo column)
+        {
+            if (column.Name == "role")
+                return new List<string> { "admin", "user" };
+            if (column.Name == "result")
+                return new List<string> { "сдан", "не сдан" };
+            if (column.Name == "difficulty")
+                return new List<string> { "легкий", "средний", "сложный" };
+            if (column.Name == "category")
+                return new List<string> { "A", "A1", "B", "B1", "C", "C1", "D", "D1", "BE", "CE", "DE" };
+            if (column.Name == "rank_name")
+                return new List<string> { "лейтенант", "старший лейтенант", "капитан", "майор", "подполковник" };
+            if (column.Name == "position_name")
+                return new List<string>
+                {
+                    "инспектор регистрационно-экзаменационного отдела",
+                    "старший инспектор",
+                    "специалист по выдаче удостоверений",
+                    "начальник регистрационно-экзаменационного отдела"
+                };
+            return null;
+        }
+
+        private List<ComboItem> LoadForeignKeyItems(ColumnInfo column)
+        {
+            string sql = null;
+
+            if (column.Name == "school_id")
+                sql = "select school_id as id, school_name as title from akulova_vs.driving_schools order by school_name";
+            else if (column.Name == "instructor_id")
+                sql = "select instructor_id as id, concat_ws(' ', last_name, first_name, middle_name) as title from akulova_vs.instructors order by last_name, first_name";
+            else if (column.Name == "driver_id")
+                sql = "select driver_id as id, concat_ws(' ', last_name, first_name, middle_name) as title from akulova_vs.drivers order by last_name, first_name";
+            else if (column.Name == "route_id")
+                sql = "select route_id as id, route_name as title from akulova_vs.exam_routes order by route_name";
+            else if (column.Name == "inspector_id")
+                sql = "select inspector_id as id, concat_ws(' ', last_name, first_name, middle_name) as title from akulova_vs.traffic_inspectors order by last_name, first_name";
+            else if (column.Name == "officer_id")
+                sql = "select officer_id as id, concat_ws(' ', last_name, first_name, middle_name) as title from akulova_vs.traffic_officers order by last_name, first_name";
+
+            if (sql == null)
+                return null;
+
+            List<ComboItem> items = new List<ComboItem>();
+            if (column.IsNullable)
+                items.Add(new ComboItem(null, "— не выбрано —"));
+
+            using (NpgsqlConnection connection = new NpgsqlConnection(connectionString))
+            {
+                connection.Open();
+                using (NpgsqlCommand command = new NpgsqlCommand(sql, connection))
+                using (NpgsqlDataReader reader = command.ExecuteReader())
+                {
+                    while (reader.Read())
+                    {
+                        items.Add(new ComboItem(Convert.ToInt32(reader["id"]), reader["title"].ToString()));
+                    }
+                }
+            }
+
+            return items;
+        }
+
+        private int GetMaxLength(ColumnInfo column)
+        {
+            Dictionary<string, int> limits = new Dictionary<string, int>
+            {
+                { "school_name", 150 },
+                { "phone", 25 },
+                { "last_name", 80 },
+                { "first_name", 80 },
+                { "middle_name", 80 },
+                { "license_number", 30 },
+                { "route_name", 120 },
+                { "difficulty", 30 },
+                { "rank_name", 80 },
+                { "position_name", 120 },
+                { "result", 20 },
+                { "category", 10 },
+                { "login", 80 }
+            };
+
+            return limits.ContainsKey(column.Name) ? limits[column.Name] : 300;
+        }
+
+        private void ValidateUserInput()
+        {
+            foreach (FieldBinding binding in bindings)
+            {
+                ColumnInfo column = binding.Column;
+
+                if (binding.IsPasswordField)
+                {
+                    string password = GetPasswordValue();
+                    if (!isEdit && string.IsNullOrWhiteSpace(password))
+                        throw new ArgumentException("Введите пароль для нового пользователя.");
+                    if (!string.IsNullOrWhiteSpace(password) && password.Length < 4)
+                        throw new ArgumentException("Пароль должен содержать минимум 4 символа.");
+                    continue;
+                }
+
+                if (!column.IsNullable && IsEmptyControl(binding.Control))
+                    throw new ArgumentException("Заполните обязательное поле: " + GetLabelText(column) + ".");
+
+                ValidateColumnFormat(binding);
+            }
+        }
+
+        private bool IsEmptyControl(Control control)
+        {
+            if (control is TextBox)
+                return string.IsNullOrWhiteSpace(((TextBox)control).Text);
+
+            if (control is ComboBox)
+            {
+                ComboBox comboBox = (ComboBox)control;
+                if (comboBox.SelectedItem == null)
+                    return true;
+
+                ComboItem item = comboBox.SelectedItem as ComboItem;
+                return item != null && !item.Id.HasValue;
+            }
+
+            if (control is DateTimePicker)
+            {
+                DateTimePicker picker = (DateTimePicker)control;
+                return picker.ShowCheckBox && !picker.Checked;
+            }
+
+            return false;
+        }
+
+        private void ValidateColumnFormat(FieldBinding binding)
+        {
+            ColumnInfo column = binding.Column;
+            string value = GetControlText(binding.Control).Trim();
+
+            if (string.IsNullOrWhiteSpace(value))
+                return;
+
+            if (column.Name == "last_name" || column.Name == "first_name" || column.Name == "middle_name")
+            {
+                if (!Regex.IsMatch(value, @"^[А-Яа-яЁёA-Za-z\s\-]+$"))
+                    throw new ArgumentException(GetLabelText(column) + " может содержать только буквы, пробелы и дефис.");
+            }
+
+            if (column.Name == "phone")
+            {
+                if (!Regex.IsMatch(value, @"^\+?[0-9\s\-\(\)]{7,25}$"))
+                    throw new ArgumentException("Телефон можно вводить только цифрами, пробелами, скобками, дефисом и знаком +.");
+            }
+
+            if (column.Name == "login")
+            {
+                if (!Regex.IsMatch(value, @"^[A-Za-z0-9_.\-]{3,80}$"))
+                    throw new ArgumentException("Логин должен содержать 3–80 символов: латинские буквы, цифры, точка, дефис или подчёркивание.");
+            }
+
+            if (column.Name == "license_number")
+            {
+                if (!Regex.IsMatch(value, @"^[A-Za-zА-Яа-яЁё0-9\-\/]{3,30}$"))
+                    throw new ArgumentException("Номер удостоверения может содержать буквы, цифры, дефис и символ /.");
+            }
+
+            if (column.Name == "birth_date" && binding.Control is DateTimePicker)
+            {
+                DateTime date = ((DateTimePicker)binding.Control).Value.Date;
+                if (date > DateTime.Today.AddYears(-16))
+                    throw new ArgumentException("Водителю должно быть не меньше 16 лет.");
+            }
+        }
+
+        private string GetControlText(Control control)
+        {
+            if (control is TextBox)
+                return ((TextBox)control).Text;
+            if (control is ComboBox && ((ComboBox)control).SelectedItem != null)
+                return ((ComboBox)control).SelectedItem.ToString();
+            if (control is DateTimePicker)
+                return ((DateTimePicker)control).Value.ToShortDateString();
+            return "";
+        }
+
+        private void BtnSave_Click(object sender, EventArgs e)
+        {
+            try
+            {
+                ValidateUserInput();
+
+                if (table.Name == "app_users")
+                    SaveAppUser();
+                else
+                    SaveGenericRecord();
+
+                DialogResult = DialogResult.OK;
+                Close();
+            }
+            catch (Exception ex)
+            {
+                MessageBox.Show(AppError.ToUserMessage(ex, "Не удалось сохранить запись."),
+                    "Ошибка сохранения", MessageBoxButtons.OK, MessageBoxIcon.Error);
+            }
+        }
+
+
+        private void EnsureIdentitySequenceIfNeeded(NpgsqlConnection connection)
+        {
+            if (string.IsNullOrWhiteSpace(table.PrimaryKey))
+                return;
+
+            string tableForPg = MainForm.DbSchema + "." + table.Name;
+            string sequenceName = null;
+
+            using (NpgsqlCommand sequenceCommand = new NpgsqlCommand("select pg_get_serial_sequence(cast(@table_name as text), cast(@column_name as text));", connection))
+            {
+                sequenceCommand.Parameters.AddWithValue("@table_name", tableForPg);
+                sequenceCommand.Parameters.AddWithValue("@column_name", table.PrimaryKey);
+                object result = sequenceCommand.ExecuteScalar();
+                if (result != null && result != DBNull.Value)
+                    sequenceName = result.ToString();
+            }
+
+            if (string.IsNullOrWhiteSpace(sequenceName))
+                return;
+
+            string sql = "select setval(cast(cast(@sequence_name as text) as regclass), " +
+                         "coalesce((select max(" + MainForm.Quote(table.PrimaryKey) + ") from " + MainForm.FullName(table.Name) + "), 0) + 1, false);";
+
+            using (NpgsqlCommand resetCommand = new NpgsqlCommand(sql, connection))
+            {
+                resetCommand.Parameters.AddWithValue("@sequence_name", sequenceName);
+                resetCommand.ExecuteNonQuery();
+            }
+        }
+
+        private void SaveGenericRecord()
+        {
+            List<string> columns = new List<string>();
+            List<string> values = new List<string>();
+            List<string> sets = new List<string>();
+
+            using (NpgsqlConnection connection = new NpgsqlConnection(connectionString))
+            {
+                connection.Open();
+                DbAppContext.Apply(connection);
+                if (!isEdit)
+                    EnsureIdentitySequenceIfNeeded(connection);
+
+                using (NpgsqlCommand command = new NpgsqlCommand())
+                {
+                    command.Connection = connection;
+
+                    foreach (FieldBinding binding in bindings)
+                    {
+                        object value = GetValue(binding);
+                        string parameterName = "@" + binding.Column.Name;
+
+                        columns.Add(MainForm.Quote(binding.Column.Name));
+                        values.Add(parameterName);
+                        sets.Add(MainForm.Quote(binding.Column.Name) + " = " + parameterName);
+                        command.Parameters.AddWithValue(parameterName, value ?? DBNull.Value);
+                    }
+
+                    if (isEdit)
+                    {
+                        string sql = "update " + MainForm.FullName(table.Name) + " set " + string.Join(", ", sets.ToArray()) +
+                                     " where " + MainForm.Quote(table.PrimaryKey) + " = @pk";
+                        command.CommandText = sql;
+                        command.Parameters.AddWithValue("@pk", row[table.PrimaryKey]);
+                    }
+                    else
+                    {
+                        string sql = "insert into " + MainForm.FullName(table.Name) + " (" + string.Join(", ", columns.ToArray()) +
+                                     ") values (" + string.Join(", ", values.ToArray()) + ")";
+                        command.CommandText = sql;
+                    }
+
+                    command.ExecuteNonQuery();
+                    DbAppContext.MarkLatestAuditLogin(connection, table.Name, isEdit ? row[table.PrimaryKey] : null);
+                }
+            }
+        }
+
+        private void SaveAppUser()
+        {
+            string login = GetTextValue("login");
+            string password = GetPasswordValue();
+            string role = GetComboValue("role");
+            object isActive = GetColumnValue("is_active");
+            object instructorId = GetColumnValue("instructor_id");
+            object driverId = GetColumnValue("driver_id");
+
+            if (string.IsNullOrWhiteSpace(login))
+                throw new Exception("Логин не может быть пустым.");
+
+            if (!isEdit && string.IsNullOrWhiteSpace(password))
+                throw new Exception("Для нового пользователя нужно указать пароль.");
+
+            if (role == "admin")
+                driverId = DBNull.Value;
+
+            if (role == "user" && (driverId == null || driverId == DBNull.Value))
+                throw new Exception("Для обычного пользователя нужно выбрать водителя. Так пользователь будет видеть только свои данные.");
+
+            using (NpgsqlConnection connection = new NpgsqlConnection(connectionString))
+            {
+                connection.Open();
+                DbAppContext.Apply(connection);
+                if (!isEdit)
+                    EnsureIdentitySequenceIfNeeded(connection);
+
+                using (NpgsqlCommand command = new NpgsqlCommand())
+                {
+                    command.Connection = connection;
+                    command.Parameters.AddWithValue("@login", login);
+                    command.Parameters.AddWithValue("@role", role);
+                    command.Parameters.AddWithValue("@is_active", isActive ?? DBNull.Value);
+                    command.Parameters.AddWithValue("@instructor_id", instructorId ?? DBNull.Value);
+                    command.Parameters.AddWithValue("@driver_id", driverId ?? DBNull.Value);
+
+                    if (isEdit)
+                    {
+                        if (string.IsNullOrWhiteSpace(password))
+                        {
+                            command.CommandText = @"
+                                update akulova_vs.app_users
+                                set login = @login,
+                                    role = cast(@role as akulova_vs.app_role),
+                                    is_active = @is_active,
+                                    instructor_id = @instructor_id,
+                                    driver_id = @driver_id
+                                where user_id = @user_id;";
+                        }
+                        else
+                        {
+                            command.CommandText = @"
+                                update akulova_vs.app_users
+                                set login = @login,
+                                    password_hash = akulova_vs.fn_hash_password(cast(@password as text)),
+                                    role = cast(@role as akulova_vs.app_role),
+                                    is_active = @is_active,
+                                    instructor_id = @instructor_id,
+                                    driver_id = @driver_id
+                                where user_id = @user_id;";
+                            command.Parameters.AddWithValue("@password", password);
+                        }
+                        command.Parameters.AddWithValue("@user_id", row[table.PrimaryKey]);
+                    }
+                    else
+                    {
+                        command.CommandText = @"
+                            insert into akulova_vs.app_users(login, password_hash, role, is_active, instructor_id, driver_id)
+                            values (@login, akulova_vs.fn_hash_password(cast(@password as text)), cast(@role as akulova_vs.app_role), @is_active, @instructor_id, @driver_id);";
+                        command.Parameters.AddWithValue("@password", password);
+                    }
+
+                    command.ExecuteNonQuery();
+                    DbAppContext.MarkLatestAuditLogin(connection, table.Name, isEdit ? row[table.PrimaryKey] : null);
+                }
+            }
+        }
+
+        private string GetTextValue(string columnName)
+        {
+            foreach (FieldBinding binding in bindings)
+            {
+                if (binding.Column.Name == columnName && binding.Control is TextBox)
+                    return ((TextBox)binding.Control).Text.Trim();
+            }
+            return "";
+        }
+
+        private string GetPasswordValue()
+        {
+            foreach (FieldBinding binding in bindings)
+            {
+                if (binding.IsPasswordField && binding.Control is TextBox)
+                    return ((TextBox)binding.Control).Text;
+            }
+            return "";
+        }
+
+        private string GetComboValue(string columnName)
+        {
+            foreach (FieldBinding binding in bindings)
+            {
+                if (binding.Column.Name == columnName && binding.Control is ComboBox)
+                {
+                    ComboBox comboBox = (ComboBox)binding.Control;
+                    return comboBox.SelectedItem == null ? "" : comboBox.SelectedItem.ToString();
+                }
+            }
+            return "";
+        }
+
+        private object GetColumnValue(string columnName)
+        {
+            foreach (FieldBinding binding in bindings)
+            {
+                if (binding.Column.Name == columnName)
+                    return GetValue(binding);
+            }
+            return DBNull.Value;
+        }
+
+        private object GetValue(FieldBinding binding)
+        {
+            ColumnInfo column = binding.Column;
+            Control control = binding.Control;
+
+            if (control is TextBox)
+            {
+                string text = ((TextBox)control).Text.Trim();
+                if (string.IsNullOrEmpty(text) && column.IsNullable)
+                    return DBNull.Value;
+
+                if (column.UdtName == "int4" || column.UdtName == "int2" || column.UdtName == "int8" || column.DataType.Contains("integer"))
+                    return int.Parse(text);
+
+                return text;
+            }
+
+            if (control is CheckBox)
+                return ((CheckBox)control).Checked;
+
+            if (control is DateTimePicker)
+            {
+                DateTimePicker picker = (DateTimePicker)control;
+                if (picker.ShowCheckBox && !picker.Checked)
+                    return DBNull.Value;
+                return picker.Value.Date;
+            }
+
+            if (control is ComboBox)
+            {
+                ComboBox comboBox = (ComboBox)control;
+                if (comboBox.SelectedItem is ComboItem)
+                {
+                    ComboItem item = (ComboItem)comboBox.SelectedItem;
+                    return item.Id.HasValue ? (object)item.Id.Value : DBNull.Value;
+                }
+
+                if (comboBox.SelectedItem == null)
+                    return column.IsNullable ? (object)DBNull.Value : "";
+
+                return comboBox.SelectedItem.ToString();
+            }
+
+            return DBNull.Value;
+        }
+
+        private void fieldsPanel_Paint(object sender, PaintEventArgs e)
+        {
+
+        }
+    }
+}

+ 120 - 0
Autoschool_PM11/autoschool/WindowsFormsApp6/RecordEditForm.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>

+ 177 - 0
Autoschool_PM11/autoschool/WindowsFormsApp6/WindowsFormsApp6.csproj

@@ -0,0 +1,177 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
+  <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
+  <PropertyGroup>
+    <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
+    <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
+    <ProjectGuid>{A3E8A077-88C9-48F0-A591-AF6AD5FC350C}</ProjectGuid>
+    <OutputType>WinExe</OutputType>
+    <RootNamespace>WindowsFormsApp6</RootNamespace>
+    <AssemblyName>WindowsFormsApp6</AssemblyName>
+    <TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>
+    <FileAlignment>512</FileAlignment>
+    <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
+    <Deterministic>true</Deterministic>
+    <NuGetPackageImportStamp>
+    </NuGetPackageImportStamp>
+  </PropertyGroup>
+  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
+    <PlatformTarget>AnyCPU</PlatformTarget>
+    <DebugSymbols>true</DebugSymbols>
+    <DebugType>full</DebugType>
+    <Optimize>false</Optimize>
+    <OutputPath>bin\Debug\</OutputPath>
+    <DefineConstants>DEBUG;TRACE</DefineConstants>
+    <ErrorReport>prompt</ErrorReport>
+    <WarningLevel>4</WarningLevel>
+  </PropertyGroup>
+  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
+    <PlatformTarget>AnyCPU</PlatformTarget>
+    <DebugType>pdbonly</DebugType>
+    <Optimize>true</Optimize>
+    <OutputPath>bin\Release\</OutputPath>
+    <DefineConstants>TRACE</DefineConstants>
+    <ErrorReport>prompt</ErrorReport>
+    <WarningLevel>4</WarningLevel>
+  </PropertyGroup>
+  <ItemGroup>
+    <Reference Include="EPPlus, Version=8.4.0.0, Culture=neutral, PublicKeyToken=ea159fdaa78159a1, processorArchitecture=MSIL">
+      <HintPath>..\packages\EPPlus.8.4.0\lib\net462\EPPlus.dll</HintPath>
+    </Reference>
+    <Reference Include="EPPlus.Interfaces, Version=8.4.0.0, Culture=neutral, PublicKeyToken=a694d7f3b0907a61, processorArchitecture=MSIL">
+      <HintPath>..\packages\EPPlus.Interfaces.8.4.0\lib\net462\EPPlus.Interfaces.dll</HintPath>
+    </Reference>
+    <Reference Include="Microsoft.Bcl.AsyncInterfaces, Version=6.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
+      <HintPath>..\packages\Microsoft.Bcl.AsyncInterfaces.6.0.0\lib\net461\Microsoft.Bcl.AsyncInterfaces.dll</HintPath>
+    </Reference>
+    <Reference Include="Microsoft.Bcl.HashCode, Version=1.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
+      <HintPath>..\packages\Microsoft.Bcl.HashCode.1.1.1\lib\net461\Microsoft.Bcl.HashCode.dll</HintPath>
+    </Reference>
+    <Reference Include="Microsoft.IO.RecyclableMemoryStream, Version=3.0.1.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL">
+      <HintPath>..\packages\Microsoft.IO.RecyclableMemoryStream.3.0.1\lib\netstandard2.0\Microsoft.IO.RecyclableMemoryStream.dll</HintPath>
+    </Reference>
+    <Reference Include="Microsoft.Office.Interop.Excel, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c, processorArchitecture=MSIL">
+      <HintPath>..\packages\Microsoft.Office.Interop.Excel.16.0.18925.20022\lib\net40\Microsoft.Office.Interop.Excel.dll</HintPath>
+      <EmbedInteropTypes>True</EmbedInteropTypes>
+    </Reference>
+    <Reference Include="Npgsql, Version=6.0.10.0, Culture=neutral, PublicKeyToken=5d8b90d52f46fda7, processorArchitecture=MSIL">
+      <HintPath>..\packages\Npgsql.6.0.10\lib\netstandard2.0\Npgsql.dll</HintPath>
+    </Reference>
+    <Reference Include="PresentationCore" />
+    <Reference Include="System" />
+    <Reference Include="System.Buffers, Version=4.0.3.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
+      <HintPath>..\packages\System.Buffers.4.5.1\lib\net461\System.Buffers.dll</HintPath>
+    </Reference>
+    <Reference Include="System.Collections.Immutable, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
+      <HintPath>..\packages\System.Collections.Immutable.6.0.0\lib\net461\System.Collections.Immutable.dll</HintPath>
+    </Reference>
+    <Reference Include="System.ComponentModel.Annotations, Version=4.2.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
+      <HintPath>..\packages\System.ComponentModel.Annotations.5.0.0\lib\net461\System.ComponentModel.Annotations.dll</HintPath>
+    </Reference>
+    <Reference Include="System.ComponentModel.DataAnnotations" />
+    <Reference Include="System.configuration" />
+    <Reference Include="System.Core" />
+    <Reference Include="System.Diagnostics.DiagnosticSource, Version=6.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
+      <HintPath>..\packages\System.Diagnostics.DiagnosticSource.6.0.0\lib\net461\System.Diagnostics.DiagnosticSource.dll</HintPath>
+    </Reference>
+    <Reference Include="System.Memory, Version=4.0.1.2, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
+      <HintPath>..\packages\System.Memory.4.5.5\lib\net461\System.Memory.dll</HintPath>
+    </Reference>
+    <Reference Include="System.Numerics" />
+    <Reference Include="System.Numerics.Vectors, Version=4.1.4.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
+      <HintPath>..\packages\System.Numerics.Vectors.4.5.0\lib\net46\System.Numerics.Vectors.dll</HintPath>
+    </Reference>
+    <Reference Include="System.Runtime.CompilerServices.Unsafe, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
+      <HintPath>..\packages\System.Runtime.CompilerServices.Unsafe.6.0.0\lib\net461\System.Runtime.CompilerServices.Unsafe.dll</HintPath>
+    </Reference>
+    <Reference Include="System.Security" />
+    <Reference Include="System.Security.Cryptography.Xml, Version=8.0.0.2, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
+      <HintPath>..\packages\System.Security.Cryptography.Xml.8.0.2\lib\net462\System.Security.Cryptography.Xml.dll</HintPath>
+    </Reference>
+    <Reference Include="System.Text.Encodings.Web, Version=6.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
+      <HintPath>..\packages\System.Text.Encodings.Web.6.0.0\lib\net461\System.Text.Encodings.Web.dll</HintPath>
+    </Reference>
+    <Reference Include="System.Text.Json, Version=6.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
+      <HintPath>..\packages\System.Text.Json.6.0.0\lib\net461\System.Text.Json.dll</HintPath>
+    </Reference>
+    <Reference Include="System.Threading.Channels, Version=6.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
+      <HintPath>..\packages\System.Threading.Channels.6.0.0\lib\net461\System.Threading.Channels.dll</HintPath>
+    </Reference>
+    <Reference Include="System.Threading.Tasks.Extensions, Version=4.2.0.1, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
+      <HintPath>..\packages\System.Threading.Tasks.Extensions.4.5.4\lib\net461\System.Threading.Tasks.Extensions.dll</HintPath>
+    </Reference>
+    <Reference Include="System.ValueTuple, Version=4.0.3.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
+      <HintPath>..\packages\System.ValueTuple.4.5.0\lib\net47\System.ValueTuple.dll</HintPath>
+    </Reference>
+    <Reference Include="System.Xml.Linq" />
+    <Reference Include="System.Data.DataSetExtensions" />
+    <Reference Include="Microsoft.CSharp" />
+    <Reference Include="System.Data" />
+    <Reference Include="System.Deployment" />
+    <Reference Include="System.Drawing" />
+    <Reference Include="System.Net.Http" />
+    <Reference Include="System.Windows.Forms" />
+    <Reference Include="System.Xml" />
+  </ItemGroup>
+  <ItemGroup>
+    <Compile Include="LoginForm.cs">
+      <SubType>Form</SubType>
+    </Compile>
+    <Compile Include="LoginForm.Designer.cs">
+      <DependentUpon>LoginForm.cs</DependentUpon>
+    </Compile>
+    <Compile Include="MainForm.cs">
+      <SubType>Form</SubType>
+    </Compile>
+    <Compile Include="MainForm.Designer.cs">
+      <DependentUpon>MainForm.cs</DependentUpon>
+    </Compile>
+    <Compile Include="Program.cs" />
+    <Compile Include="RecordEditForm.cs">
+      <SubType>Form</SubType>
+    </Compile>
+    <Compile Include="RecordEditForm.Designer.cs">
+      <DependentUpon>RecordEditForm.cs</DependentUpon>
+    </Compile>
+    <Compile Include="Properties\AssemblyInfo.cs" />
+    <EmbeddedResource Include="LoginForm.resx">
+      <DependentUpon>LoginForm.cs</DependentUpon>
+    </EmbeddedResource>
+    <EmbeddedResource Include="RecordEditForm.resx">
+      <DependentUpon>RecordEditForm.cs</DependentUpon>
+    </EmbeddedResource>
+    <EmbeddedResource Include="MainForm.resx">
+      <DependentUpon>MainForm.cs</DependentUpon>
+    </EmbeddedResource>
+    <EmbeddedResource Include="Properties\Resources.resx">
+      <Generator>ResXFileCodeGenerator</Generator>
+      <LastGenOutput>Resources.Designer.cs</LastGenOutput>
+      <SubType>Designer</SubType>
+    </EmbeddedResource>
+    <Compile Include="Properties\Resources.Designer.cs">
+      <AutoGen>True</AutoGen>
+      <DependentUpon>Resources.resx</DependentUpon>
+    </Compile>
+    <None Include="packages.config" />
+    <None Include="Properties\Settings.settings">
+      <Generator>SettingsSingleFileGenerator</Generator>
+      <LastGenOutput>Settings.Designer.cs</LastGenOutput>
+    </None>
+    <Compile Include="Properties\Settings.Designer.cs">
+      <AutoGen>True</AutoGen>
+      <DependentUpon>Settings.settings</DependentUpon>
+      <DesignTimeSharedInput>True</DesignTimeSharedInput>
+    </Compile>
+  </ItemGroup>
+  <ItemGroup>
+    <None Include="App.config" />
+  </ItemGroup>
+  <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
+  <Import Project="..\packages\System.Text.Json.6.0.0\build\System.Text.Json.targets" Condition="Exists('..\packages\System.Text.Json.6.0.0\build\System.Text.Json.targets')" />
+  <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
+    <PropertyGroup>
+      <ErrorText>Данный проект ссылается на пакеты NuGet, отсутствующие на этом компьютере. Используйте восстановление пакетов NuGet, чтобы скачать их.  Дополнительную информацию см. по адресу: http://go.microsoft.com/fwlink/?LinkID=322105. Отсутствует следующий файл: {0}.</ErrorText>
+    </PropertyGroup>
+    <Error Condition="!Exists('..\packages\System.Text.Json.6.0.0\build\System.Text.Json.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\System.Text.Json.6.0.0\build\System.Text.Json.targets'))" />
+  </Target>
+</Project>

+ 23 - 0
Autoschool_PM11/autoschool/WindowsFormsApp6/packages.config

@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<packages>
+  <package id="EPPlus" version="8.4.0" targetFramework="net472" />
+  <package id="EPPlus.Interfaces" version="8.4.0" targetFramework="net472" />
+  <package id="Microsoft.Bcl.AsyncInterfaces" version="6.0.0" targetFramework="net472" />
+  <package id="Microsoft.Bcl.HashCode" version="1.1.1" targetFramework="net472" />
+  <package id="Microsoft.IO.RecyclableMemoryStream" version="3.0.1" targetFramework="net472" />
+  <package id="Microsoft.Office.Interop.Excel" version="16.0.18925.20022" targetFramework="net472" />
+  <package id="Npgsql" version="6.0.10" targetFramework="net472" />
+  <package id="System.Buffers" version="4.5.1" targetFramework="net472" />
+  <package id="System.Collections.Immutable" version="6.0.0" targetFramework="net472" />
+  <package id="System.ComponentModel.Annotations" version="5.0.0" targetFramework="net472" />
+  <package id="System.Diagnostics.DiagnosticSource" version="6.0.0" targetFramework="net472" />
+  <package id="System.Memory" version="4.5.5" targetFramework="net472" />
+  <package id="System.Numerics.Vectors" version="4.5.0" targetFramework="net472" />
+  <package id="System.Runtime.CompilerServices.Unsafe" version="6.0.0" targetFramework="net472" />
+  <package id="System.Security.Cryptography.Xml" version="8.0.2" targetFramework="net472" />
+  <package id="System.Text.Encodings.Web" version="6.0.0" targetFramework="net472" />
+  <package id="System.Text.Json" version="6.0.0" targetFramework="net472" />
+  <package id="System.Threading.Channels" version="6.0.0" targetFramework="net472" />
+  <package id="System.Threading.Tasks.Extensions" version="4.5.4" targetFramework="net472" />
+  <package id="System.ValueTuple" version="4.5.0" targetFramework="net472" />
+</packages>

+ 122 - 0
README.md

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

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

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