Browse Source

Добавьте файлы проекта.

Vanya 1 week ago
parent
commit
0fb061c5f7
29 changed files with 3711 additions and 0 deletions
  1. 28 0
      TravelAgencyWinForms.sln
  2. BIN
      TravelAgencyWinForms/Assets/booking_report.png
  3. BIN
      TravelAgencyWinForms/Assets/empty_results.png
  4. BIN
      TravelAgencyWinForms/Assets/login_welcome.png
  5. BIN
      TravelAgencyWinForms/Assets/main_dashboard_banner.png
  6. 128 0
      TravelAgencyWinForms/Data/Db.cs
  7. 289 0
      TravelAgencyWinForms/Forms/EntityEditDialog.cs
  8. 148 0
      TravelAgencyWinForms/Forms/LoginForm.Designer.cs
  9. 87 0
      TravelAgencyWinForms/Forms/LoginForm.cs
  10. 165 0
      TravelAgencyWinForms/Forms/MainForm.Designer.cs
  11. 251 0
      TravelAgencyWinForms/Forms/MainForm.cs
  12. 153 0
      TravelAgencyWinForms/Forms/ReportsForm.Designer.cs
  13. 210 0
      TravelAgencyWinForms/Forms/ReportsForm.cs
  14. 164 0
      TravelAgencyWinForms/Forms/TableEditorForm.Designer.cs
  15. 408 0
      TravelAgencyWinForms/Forms/TableEditorForm.cs
  16. 22 0
      TravelAgencyWinForms/Models/CurrentUser.cs
  17. 19 0
      TravelAgencyWinForms/Models/EntityConfig.cs
  18. 29 0
      TravelAgencyWinForms/Models/FieldConfig.cs
  19. 16 0
      TravelAgencyWinForms/Program.cs
  20. 67 0
      TravelAgencyWinForms/Services/AuthService.cs
  21. 252 0
      TravelAgencyWinForms/Services/EntityConfigs.cs
  22. 23 0
      TravelAgencyWinForms/TravelAgencyWinForms.csproj
  23. 245 0
      TravelAgencyWinForms/Utils/AppTheme.cs
  24. 58 0
      TravelAgencyWinForms/Utils/CalculatedFields.cs
  25. 138 0
      TravelAgencyWinForms/Utils/ErrorHelper.cs
  26. 126 0
      TravelAgencyWinForms/Utils/PresentationHelper.cs
  27. 1 0
      TravelAgencyWinForms/connection.example.txt
  28. 1 0
      TravelAgencyWinForms/connection.txt
  29. 683 0
      installer/installer.vdproj

+ 28 - 0
TravelAgencyWinForms.sln

@@ -0,0 +1,28 @@
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TravelAgencyWinForms", "TravelAgencyWinForms\TravelAgencyWinForms.csproj", "{562A19DA-602D-4DD0-A54A-7822057E5CBD}"
+EndProject
+Project("{54435603-DBB4-11D2-8724-00A0C9A8B90C}") = "installer", "installer\installer.vdproj", "{52B20422-474D-5734-629F-99A1F7DE9787}"
+EndProject
+Global
+	GlobalSection(SolutionConfigurationPlatforms) = preSolution
+		Debug|Any CPU = Debug|Any CPU
+		Release|Any CPU = Release|Any CPU
+	EndGlobalSection
+	GlobalSection(ProjectConfigurationPlatforms) = postSolution
+		{562A19DA-602D-4DD0-A54A-7822057E5CBD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{562A19DA-602D-4DD0-A54A-7822057E5CBD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{562A19DA-602D-4DD0-A54A-7822057E5CBD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{562A19DA-602D-4DD0-A54A-7822057E5CBD}.Release|Any CPU.Build.0 = Release|Any CPU
+		{52B20422-474D-5734-629F-99A1F7DE9787}.Debug|Any CPU.ActiveCfg = Debug
+		{52B20422-474D-5734-629F-99A1F7DE9787}.Release|Any CPU.ActiveCfg = Release
+	EndGlobalSection
+	GlobalSection(SolutionProperties) = preSolution
+		HideSolutionNode = FALSE
+	EndGlobalSection
+	GlobalSection(ExtensibilityGlobals) = postSolution
+		SolutionGuid = {61D6BCCC-DA7B-4EFF-9CD6-020EAF0656F2}
+	EndGlobalSection
+EndGlobal

BIN
TravelAgencyWinForms/Assets/booking_report.png


BIN
TravelAgencyWinForms/Assets/empty_results.png


BIN
TravelAgencyWinForms/Assets/login_welcome.png


BIN
TravelAgencyWinForms/Assets/main_dashboard_banner.png


+ 128 - 0
TravelAgencyWinForms/Data/Db.cs

@@ -0,0 +1,128 @@
+using System.Data;
+using Npgsql;
+using TravelAgencyWinForms.Models;
+
+namespace TravelAgencyWinForms.Data;
+
+public static class Db
+{
+    private const string DefaultConnectionString = "Host=edu-pg.itiscaf.ru;Port=5432;Database=dbwork;Username=shchetnikov_id;Password=#10j$bn0&22;Search Path=shchetnikov_id;Include Error Detail=true";
+
+    public static string ConnectionString
+    {
+        get
+        {
+            var localPath = Path.Combine(AppContext.BaseDirectory, "connection.txt");
+            if (File.Exists(localPath))
+            {
+                var text = File.ReadAllText(localPath).Trim();
+                if (!string.IsNullOrWhiteSpace(text))
+                    return text;
+            }
+
+            return DefaultConnectionString;
+        }
+    }
+
+    public static NpgsqlConnection CreateConnection()
+    {
+        return new NpgsqlConnection(ConnectionString);
+    }
+
+    public static DataTable Query(string sql, params NpgsqlParameter[] parameters)
+    {
+        using var connection = CreateConnection();
+        using var command = new NpgsqlCommand(sql, connection);
+        if (parameters.Length > 0)
+            command.Parameters.AddRange(parameters);
+        using var adapter = new NpgsqlDataAdapter(command);
+        var table = new DataTable();
+        adapter.Fill(table);
+        return table;
+    }
+
+    public static int Execute(string sql, params NpgsqlParameter[] parameters)
+    {
+        using var connection = CreateConnection();
+        connection.Open();
+        ApplyAppContext(connection);
+        using var command = new NpgsqlCommand(sql, connection);
+        if (parameters.Length > 0)
+            command.Parameters.AddRange(parameters);
+        return command.ExecuteNonQuery();
+    }
+
+    public static object? Scalar(string sql, params NpgsqlParameter[] parameters)
+    {
+        using var connection = CreateConnection();
+        connection.Open();
+        using var command = new NpgsqlCommand(sql, connection);
+        if (parameters.Length > 0)
+            command.Parameters.AddRange(parameters);
+        var value = command.ExecuteScalar();
+        return value == DBNull.Value ? null : value;
+    }
+
+    public static void ResetIdentitySequences()
+    {
+        try
+        {
+            Execute("CALL sp_reset_identity_sequences();");
+        }
+        catch
+        {
+        }
+    }
+
+    public static bool TestConnection(out string message)
+    {
+        try
+        {
+            using var connection = CreateConnection();
+            connection.Open();
+            using var command = new NpgsqlCommand("select current_database(), current_schema();", connection);
+            using var reader = command.ExecuteReader();
+            if (reader.Read())
+                message = $"Подключение выполнено. База: {reader.GetString(0)}, схема: {reader.GetString(1)}";
+            else
+                message = "Подключение выполнено.";
+            return true;
+        }
+        catch (Exception ex)
+        {
+            message = ex.Message;
+            return false;
+        }
+    }
+
+    public static void SetAppContext(string login)
+    {
+        try
+        {
+            using var connection = CreateConnection();
+            connection.Open();
+            using var command = new NpgsqlCommand("CALL sp_set_app_context(@login);", connection);
+            command.Parameters.AddWithValue("login", login);
+            command.ExecuteNonQuery();
+        }
+        catch
+        {
+        }
+    }
+
+    public static void ApplyAppContext(NpgsqlConnection connection)
+    {
+        if (string.IsNullOrWhiteSpace(CurrentUser.Login))
+            return;
+
+        try
+        {
+            using var command = new NpgsqlCommand("CALL sp_set_app_context(@login);", connection);
+            command.Parameters.AddWithValue("login", CurrentUser.Login);
+            command.ExecuteNonQuery();
+        }
+        catch
+        {
+        }
+    }
+}

+ 289 - 0
TravelAgencyWinForms/Forms/EntityEditDialog.cs

@@ -0,0 +1,289 @@
+using System.Data;
+using TravelAgencyWinForms.Data;
+using TravelAgencyWinForms.Models;
+using TravelAgencyWinForms.Utils;
+
+namespace TravelAgencyWinForms.Forms;
+
+public sealed class EntityEditDialog : Form
+{
+    private readonly EntityConfig _config;
+    private readonly bool _isEdit;
+    private readonly Dictionary<string, Control> _controls = new();
+    private readonly Dictionary<string, FieldConfig> _fields = new();
+
+    public Dictionary<string, object?> Values { get; } = new();
+    public bool PasswordWasEntered { get; private set; }
+
+    public EntityEditDialog(EntityConfig config, DataRow? row = null)
+    {
+        _config = config;
+        _isEdit = row != null;
+        Text = (_isEdit ? "Изменение" : "Добавление") + " — " + config.Title;
+        StartPosition = FormStartPosition.CenterParent;
+        Width = 560;
+        Height = Math.Min(760, 180 + config.EditableFields.Count() * 46);
+
+        AppTheme.ApplyForm(this, true);
+        BuildLayout(row);
+    }
+
+    private void BuildLayout(DataRow? row)
+    {
+        var main = new TableLayoutPanel
+        {
+            Dock = DockStyle.Fill,
+            Padding = new Padding(18),
+            ColumnCount = 2,
+            RowCount = _config.EditableFields.Count() + 2,
+            AutoScroll = true
+        };
+        main.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 180));
+        main.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
+
+        var currentRow = 0;
+        foreach (var field in _config.EditableFields)
+        {
+            _fields[field.Name] = field;
+            main.RowStyles.Add(new RowStyle(SizeType.Absolute, 42));
+
+            var label = new Label
+            {
+                Text = field.Label + (field.Required ? " *" : string.Empty),
+                AutoSize = true,
+                Anchor = AnchorStyles.Left,
+                ForeColor = AppTheme.Text
+            };
+
+            var control = CreateControl(field, row);
+            control.Anchor = AnchorStyles.Left | AnchorStyles.Right;
+
+            if (_isEdit && (field.ReadOnlyOnEdit || field.IsKey))
+                control.Enabled = false;
+
+            main.Controls.Add(label, 0, currentRow);
+            main.Controls.Add(control, 1, currentRow);
+            _controls[field.Name] = control;
+            currentRow++;
+        }
+
+        var buttons = new FlowLayoutPanel { FlowDirection = FlowDirection.RightToLeft, Dock = DockStyle.Fill };
+        var saveButton = new Button { Text = "Сохранить", Width = 120, Height = 36 };
+        var cancelButton = new Button { Text = "Отмена", Width = 120, Height = 36 };
+        AppTheme.StylePrimaryButton(saveButton);
+        AppTheme.StyleSecondaryButton(cancelButton);
+        saveButton.Click += (_, _) => Save();
+        cancelButton.Click += (_, _) => DialogResult = DialogResult.Cancel;
+        buttons.Controls.Add(saveButton);
+        buttons.Controls.Add(cancelButton);
+
+        main.RowStyles.Add(new RowStyle(SizeType.Absolute, 46));
+        main.Controls.Add(buttons, 0, currentRow);
+        main.SetColumnSpan(buttons, 2);
+        Controls.Add(main);
+    }
+
+    private Control CreateControl(FieldConfig field, DataRow? row)
+    {
+        object? value = null;
+        if (row != null && row.Table.Columns.Contains(field.Name) && row[field.Name] != DBNull.Value)
+            value = row[field.Name];
+
+        switch (field.Kind)
+        {
+            case FieldKind.Date:
+                var datePicker = new DateTimePicker
+                {
+                    Format = DateTimePickerFormat.Short,
+                    Width = 240,
+                    ShowCheckBox = !field.Required
+                };
+                if (value is DateTime dt)
+                {
+                    datePicker.Value = dt;
+                    datePicker.Checked = true;
+                }
+                else
+                {
+                    datePicker.Value = DateTime.Today;
+                    datePicker.Checked = field.Required;
+                }
+                return datePicker;
+
+            case FieldKind.Boolean:
+                var checkBox = new CheckBox { Text = "Да", AutoSize = true };
+                checkBox.Checked = value == null || Convert.ToBoolean(value);
+                return checkBox;
+
+            case FieldKind.Lookup:
+                var comboBox = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 320 };
+                AppTheme.StyleComboBox(comboBox);
+                LoadLookup(comboBox, field, value);
+                return comboBox;
+
+            case FieldKind.Enum:
+                var enumBox = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 320 };
+                AppTheme.StyleComboBox(enumBox);
+                foreach (var option in field.Options)
+                    enumBox.Items.Add(option);
+                if (value != null && enumBox.Items.Contains(Convert.ToString(value)))
+                    enumBox.SelectedItem = Convert.ToString(value);
+                else if (enumBox.Items.Count > 0)
+                    enumBox.SelectedIndex = 0;
+                return enumBox;
+
+            case FieldKind.Password:
+                var passwordBox = new TextBox { Width = 320, UseSystemPasswordChar = true, Text = string.Empty };
+                AppTheme.StyleTextBox(passwordBox);
+                return passwordBox;
+
+            default:
+                var textBox = new TextBox { Width = 320, Text = value?.ToString() ?? string.Empty };
+                AppTheme.StyleTextBox(textBox);
+                return textBox;
+        }
+    }
+
+    private static void LoadLookup(ComboBox comboBox, FieldConfig field, object? selectedValue)
+    {
+        if (string.IsNullOrWhiteSpace(field.LookupSql))
+            return;
+
+        var source = Db.Query(field.LookupSql);
+        var table = new DataTable();
+        table.Columns.Add(field.LookupValueColumn, typeof(object));
+        table.Columns.Add(field.LookupDisplayColumn, typeof(string));
+
+        if (!field.Required)
+            table.Rows.Add(DBNull.Value, "—");
+
+        foreach (DataRow sourceRow in source.Rows)
+            table.Rows.Add(sourceRow[field.LookupValueColumn] == DBNull.Value ? DBNull.Value : sourceRow[field.LookupValueColumn], Convert.ToString(sourceRow[field.LookupDisplayColumn]) ?? string.Empty);
+
+        comboBox.DataSource = table;
+        comboBox.ValueMember = field.LookupValueColumn;
+        comboBox.DisplayMember = field.LookupDisplayColumn;
+
+        if (selectedValue != null && selectedValue != DBNull.Value)
+            comboBox.SelectedValue = selectedValue;
+        else if (comboBox.Items.Count > 0)
+            comboBox.SelectedIndex = 0;
+    }
+
+    private void Save()
+    {
+        Values.Clear();
+        PasswordWasEntered = false;
+
+        foreach (var (name, control) in _controls)
+        {
+            var field = _fields[name];
+            object? value;
+            try
+            {
+                value = ReadControlValue(field, control);
+            }
+            catch (Exception ex)
+            {
+                MessageBox.Show(ErrorHelper.ToUserMessage(ex), "Проверка", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+                return;
+            }
+
+            if (field.Required && (value == null || string.IsNullOrWhiteSpace(Convert.ToString(value))))
+            {
+                MessageBox.Show($"Поле '{field.Label}' должно быть заполнено.", "Проверка", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+                return;
+            }
+
+            if (field.Kind == FieldKind.Password)
+            {
+                PasswordWasEntered = !string.IsNullOrWhiteSpace(Convert.ToString(value));
+                if (!_isEdit && !PasswordWasEntered)
+                {
+                    MessageBox.Show("Введите пароль.", "Проверка", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+                    return;
+                }
+            }
+
+            Values[name] = value;
+        }
+
+        if (!ValidateDomainRules())
+            return;
+
+        DialogResult = DialogResult.OK;
+        Close();
+    }
+
+    private object? ReadControlValue(FieldConfig field, Control control)
+    {
+        return field.Kind switch
+        {
+            FieldKind.Date => ((DateTimePicker)control).Checked ? ((DateTimePicker)control).Value.Date : null,
+            FieldKind.Boolean => ((CheckBox)control).Checked,
+            FieldKind.Lookup => ((ComboBox)control).SelectedValue == DBNull.Value ? null : ((ComboBox)control).SelectedValue,
+            FieldKind.Enum => ((ComboBox)control).SelectedItem?.ToString(),
+            FieldKind.Integer => ParseInteger(field, control.Text),
+            FieldKind.Decimal => ParseDecimal(field, control.Text),
+            _ => string.IsNullOrWhiteSpace(control.Text.Trim()) ? null : control.Text.Trim()
+        };
+    }
+
+    private static int? ParseInteger(FieldConfig field, string text)
+    {
+        var value = text.Trim();
+        if (string.IsNullOrWhiteSpace(value))
+            return null;
+        if (!int.TryParse(value, out var number))
+            throw new InvalidOperationException($"Поле '{field.Label}' должно быть целым числом.");
+        return number;
+    }
+
+    private static decimal? ParseDecimal(FieldConfig field, string text)
+    {
+        var value = text.Trim().Replace(',', '.');
+        if (string.IsNullOrWhiteSpace(value))
+            return null;
+        if (!decimal.TryParse(value, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var number))
+            throw new InvalidOperationException($"Поле '{field.Label}' должно быть числом.");
+        return number;
+    }
+
+    private bool ValidateDomainRules()
+    {
+        try
+        {
+            if (_config.TableName == "positions" && Values.TryGetValue("salary", out var salaryObj) && salaryObj != null && Convert.ToDecimal(salaryObj) < 0)
+                return Warn("Зарплата не может быть отрицательной.");
+
+            if (_config.TableName == "tours" && Values.TryGetValue("price", out var priceObj) && priceObj != null && Convert.ToDecimal(priceObj) < 0)
+                return Warn("Стоимость тура не может быть отрицательной.");
+
+            if (_config.TableName == "employees" && Values.TryGetValue("birth_date", out var birthObj) && birthObj is DateTime birthDate)
+            {
+                if (birthDate > DateTime.Today)
+                    return Warn("Дата рождения не может быть в будущем.");
+                if (birthDate > DateTime.Today.AddYears(-18))
+                    return Warn("Сотрудник должен быть старше 18 лет.");
+            }
+
+            if (_config.TableName == "tour_list" && Values.TryGetValue("start_date", out var startObj) && Values.TryGetValue("end_date", out var endObj) && startObj is DateTime start && endObj is DateTime end && end < start)
+                return Warn("Дата окончания не может быть раньше даты начала.");
+
+            if (_config.TableName == "bookings" && Values.TryGetValue("booking_date", out var bookingObj) && bookingObj is DateTime bookingDate && bookingDate > DateTime.Today)
+                return Warn("Дата оформления не может быть в будущем.");
+        }
+        catch (Exception ex)
+        {
+            return Warn(ErrorHelper.ToUserMessage(ex));
+        }
+
+        return true;
+    }
+
+    private static bool Warn(string message)
+    {
+        MessageBox.Show(message, "Проверка", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+        return false;
+    }
+}

+ 148 - 0
TravelAgencyWinForms/Forms/LoginForm.Designer.cs

@@ -0,0 +1,148 @@
+namespace TravelAgencyWinForms.Forms;
+
+partial class LoginForm
+{
+    private System.ComponentModel.IContainer components = null;
+    private TableLayoutPanel rootPanel;
+    private Panel imagePanel;
+    private PictureBox welcomePictureBox;
+    private Panel authPanel;
+    private Label titleLabel;
+    private Label subtitleLabel;
+    private Label loginLabel;
+    private Label passwordLabel;
+    private TextBox loginTextBox;
+    private TextBox passwordTextBox;
+    private Button loginButton;
+    private Button exitButton;
+    private Label statusLabel;
+    private TableLayoutPanel formPanel;
+    private FlowLayoutPanel buttonsPanel;
+
+    protected override void Dispose(bool disposing)
+    {
+        if (disposing && components != null)
+            components.Dispose();
+
+        base.Dispose(disposing);
+    }
+
+    private void InitializeComponent()
+    {
+        components = new System.ComponentModel.Container();
+        rootPanel = new TableLayoutPanel();
+        imagePanel = new Panel();
+        welcomePictureBox = new PictureBox();
+        authPanel = new Panel();
+        titleLabel = new Label();
+        subtitleLabel = new Label();
+        formPanel = new TableLayoutPanel();
+        loginLabel = new Label();
+        passwordLabel = new Label();
+        loginTextBox = new TextBox();
+        passwordTextBox = new TextBox();
+        buttonsPanel = new FlowLayoutPanel();
+        loginButton = new Button();
+        exitButton = new Button();
+        statusLabel = new Label();
+
+        SuspendLayout();
+
+        Text = "Вход";
+        StartPosition = FormStartPosition.CenterScreen;
+        FormBorderStyle = FormBorderStyle.FixedDialog;
+        MaximizeBox = false;
+        MinimizeBox = false;
+        ClientSize = new Size(820, 430);
+
+        rootPanel.Dock = DockStyle.Fill;
+        rootPanel.ColumnCount = 2;
+        rootPanel.RowCount = 1;
+        rootPanel.Padding = new Padding(22);
+        rootPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 350));
+        rootPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
+
+        imagePanel.Dock = DockStyle.Fill;
+        imagePanel.Padding = new Padding(10);
+        imagePanel.BorderStyle = BorderStyle.FixedSingle;
+
+        welcomePictureBox.Dock = DockStyle.Fill;
+        welcomePictureBox.SizeMode = PictureBoxSizeMode.Zoom;
+
+        imagePanel.Controls.Add(welcomePictureBox);
+
+        authPanel.Dock = DockStyle.Fill;
+        authPanel.Padding = new Padding(34, 32, 34, 24);
+        authPanel.BorderStyle = BorderStyle.FixedSingle;
+
+        titleLabel.Dock = DockStyle.Top;
+        titleLabel.Height = 42;
+        titleLabel.Text = "Турагентство";
+        titleLabel.Font = new Font("Segoe UI Semibold", 22F, FontStyle.Regular);
+
+        subtitleLabel.Dock = DockStyle.Top;
+        subtitleLabel.Height = 30;
+        subtitleLabel.Text = "Вход в информационную систему";
+        subtitleLabel.Font = new Font("Segoe UI", 10F, FontStyle.Regular);
+
+        formPanel.Dock = DockStyle.Top;
+        formPanel.Height = 180;
+        formPanel.ColumnCount = 1;
+        formPanel.RowCount = 5;
+        formPanel.Padding = new Padding(0, 14, 0, 0);
+        formPanel.RowStyles.Add(new RowStyle(SizeType.Absolute, 24));
+        formPanel.RowStyles.Add(new RowStyle(SizeType.Absolute, 38));
+        formPanel.RowStyles.Add(new RowStyle(SizeType.Absolute, 24));
+        formPanel.RowStyles.Add(new RowStyle(SizeType.Absolute, 38));
+        formPanel.RowStyles.Add(new RowStyle(SizeType.Absolute, 56));
+
+        loginLabel.AutoSize = true;
+        loginLabel.Text = "Логин";
+
+        passwordLabel.AutoSize = true;
+        passwordLabel.Text = "Пароль";
+
+        loginTextBox.Width = 260;
+        passwordTextBox.Width = 260;
+        passwordTextBox.UseSystemPasswordChar = true;
+        passwordTextBox.KeyDown += PasswordTextBox_KeyDown;
+
+        buttonsPanel.Dock = DockStyle.Fill;
+        buttonsPanel.FlowDirection = FlowDirection.LeftToRight;
+
+        loginButton.Text = "Войти";
+        loginButton.Width = 120;
+        loginButton.Height = 36;
+        loginButton.Click += LoginButton_Click;
+
+        exitButton.Text = "Выйти";
+        exitButton.Width = 120;
+        exitButton.Height = 36;
+        exitButton.Click += ExitButton_Click;
+
+        buttonsPanel.Controls.Add(loginButton);
+        buttonsPanel.Controls.Add(exitButton);
+
+        formPanel.Controls.Add(loginLabel, 0, 0);
+        formPanel.Controls.Add(loginTextBox, 0, 1);
+        formPanel.Controls.Add(passwordLabel, 0, 2);
+        formPanel.Controls.Add(passwordTextBox, 0, 3);
+        formPanel.Controls.Add(buttonsPanel, 0, 4);
+
+        statusLabel.Dock = DockStyle.Bottom;
+        statusLabel.Height = 52;
+        statusLabel.TextAlign = ContentAlignment.MiddleLeft;
+
+        authPanel.Controls.Add(statusLabel);
+        authPanel.Controls.Add(formPanel);
+        authPanel.Controls.Add(subtitleLabel);
+        authPanel.Controls.Add(titleLabel);
+
+        rootPanel.Controls.Add(imagePanel, 0, 0);
+        rootPanel.Controls.Add(authPanel, 1, 0);
+
+        Controls.Add(rootPanel);
+
+        ResumeLayout(false);
+    }
+}

+ 87 - 0
TravelAgencyWinForms/Forms/LoginForm.cs

@@ -0,0 +1,87 @@
+using TravelAgencyWinForms.Data;
+using TravelAgencyWinForms.Services;
+using TravelAgencyWinForms.Utils;
+
+namespace TravelAgencyWinForms.Forms;
+
+public partial class LoginForm : Form
+{
+    public LoginForm()
+    {
+        InitializeComponent();
+        ApplyStyle();
+        LoadIllustration();
+        loginTextBox.Text = "admin";
+    }
+
+    private void ApplyStyle()
+    {
+        AppTheme.ApplyForm(this, true);
+        AppTheme.StyleTextBox(loginTextBox);
+        AppTheme.StyleTextBox(passwordTextBox);
+        AppTheme.StylePrimaryButton(loginButton);
+        AppTheme.StyleSecondaryButton(exitButton);
+
+        titleLabel.ForeColor = AppTheme.Text;
+        subtitleLabel.ForeColor = AppTheme.Muted;
+        loginLabel.ForeColor = AppTheme.Text;
+        passwordLabel.ForeColor = AppTheme.Text;
+        statusLabel.ForeColor = AppTheme.Muted;
+        rootPanel.BackColor = AppTheme.Background;
+        imagePanel.BackColor = AppTheme.Surface;
+        authPanel.BackColor = AppTheme.Surface;
+    }
+
+    private void LoadIllustration()
+    {
+        var image = AppTheme.LoadImage("login_welcome.png");
+        if (image != null)
+            welcomePictureBox.Image = image;
+    }
+
+    private void LoginButton_Click(object? sender, EventArgs e)
+    {
+        TryLogin();
+    }
+
+    private void ExitButton_Click(object? sender, EventArgs e)
+    {
+        Close();
+    }
+
+    private void PasswordTextBox_KeyDown(object? sender, KeyEventArgs e)
+    {
+        if (e.KeyCode != Keys.Enter)
+            return;
+
+        e.SuppressKeyPress = true;
+        TryLogin();
+    }
+
+    private void TryLogin()
+    {
+        if (string.IsNullOrWhiteSpace(loginTextBox.Text) || string.IsNullOrWhiteSpace(passwordTextBox.Text))
+        {
+            statusLabel.Text = "Заполните логин и пароль.";
+            return;
+        }
+
+        Cursor = Cursors.WaitCursor;
+        try
+        {
+            if (AuthService.Login(loginTextBox.Text, passwordTextBox.Text, out var error))
+            {
+                DialogResult = DialogResult.OK;
+                Close();
+            }
+            else
+            {
+                statusLabel.Text = error;
+            }
+        }
+        finally
+        {
+            Cursor = Cursors.Default;
+        }
+    }
+}

+ 165 - 0
TravelAgencyWinForms/Forms/MainForm.Designer.cs

@@ -0,0 +1,165 @@
+namespace TravelAgencyWinForms.Forms;
+
+partial class MainForm
+{
+    private System.ComponentModel.IContainer components = null;
+    private Panel menuPanel;
+    private Panel menuHeaderPanel;
+    private FlowLayoutPanel menuItemsPanel;
+    private Label appTitleLabel;
+    private Label modeLabel;
+    private Label loginLabel;
+    private Label userNameLabel;
+    private Panel contentPanel;
+    private Panel heroPanel;
+    private PictureBox bannerPictureBox;
+    private Label heroTitleLabel;
+    private Label heroSubtitleLabel;
+    private FlowLayoutPanel statsPanel;
+    private TableLayoutPanel dashboardTablesPanel;
+    private GroupBox upcomingGroupBox;
+    private GroupBox bookingsGroupBox;
+    private DataGridView upcomingGrid;
+    private DataGridView bookingsGrid;
+
+    protected override void Dispose(bool disposing)
+    {
+        if (disposing && components != null)
+            components.Dispose();
+
+        base.Dispose(disposing);
+    }
+
+    private void InitializeComponent()
+    {
+        components = new System.ComponentModel.Container();
+        menuPanel = new Panel();
+        menuHeaderPanel = new Panel();
+        menuItemsPanel = new FlowLayoutPanel();
+        appTitleLabel = new Label();
+        modeLabel = new Label();
+        loginLabel = new Label();
+        userNameLabel = new Label();
+        contentPanel = new Panel();
+        heroPanel = new Panel();
+        bannerPictureBox = new PictureBox();
+        heroTitleLabel = new Label();
+        heroSubtitleLabel = new Label();
+        statsPanel = new FlowLayoutPanel();
+        dashboardTablesPanel = new TableLayoutPanel();
+        upcomingGroupBox = new GroupBox();
+        bookingsGroupBox = new GroupBox();
+        upcomingGrid = new DataGridView();
+        bookingsGrid = new DataGridView();
+
+        SuspendLayout();
+
+        Text = "Турагентство";
+        StartPosition = FormStartPosition.CenterScreen;
+        WindowState = FormWindowState.Maximized;
+        MinimumSize = new Size(1200, 760);
+
+        menuPanel.Dock = DockStyle.Left;
+        menuPanel.Width = 260;
+
+        menuHeaderPanel.Dock = DockStyle.Top;
+        menuHeaderPanel.Height = 140;
+        menuHeaderPanel.Padding = new Padding(18, 18, 18, 12);
+
+        appTitleLabel.Dock = DockStyle.Top;
+        appTitleLabel.Height = 34;
+        appTitleLabel.Font = new Font("Segoe UI Semibold", 18F);
+
+        modeLabel.Dock = DockStyle.Top;
+        modeLabel.Height = 24;
+        modeLabel.Font = new Font("Segoe UI", 10F);
+
+        loginLabel.Dock = DockStyle.Bottom;
+        loginLabel.Height = 20;
+        loginLabel.Font = new Font("Segoe UI", 9F);
+
+        userNameLabel.Dock = DockStyle.Bottom;
+        userNameLabel.Height = 24;
+        userNameLabel.Font = new Font("Segoe UI Semibold", 11F);
+
+        menuHeaderPanel.Controls.Add(loginLabel);
+        menuHeaderPanel.Controls.Add(userNameLabel);
+        menuHeaderPanel.Controls.Add(modeLabel);
+        menuHeaderPanel.Controls.Add(appTitleLabel);
+
+        menuItemsPanel.Dock = DockStyle.Fill;
+        menuItemsPanel.Padding = new Padding(12, 8, 12, 12);
+        menuItemsPanel.FlowDirection = FlowDirection.TopDown;
+        menuItemsPanel.WrapContents = false;
+        menuItemsPanel.AutoScroll = true;
+
+        menuPanel.Controls.Add(menuItemsPanel);
+        menuPanel.Controls.Add(menuHeaderPanel);
+
+        contentPanel.Dock = DockStyle.Fill;
+        contentPanel.Padding = new Padding(18);
+
+        heroPanel.Dock = DockStyle.Top;
+        heroPanel.Height = 260;
+        heroPanel.Padding = new Padding(24);
+        heroPanel.BorderStyle = BorderStyle.FixedSingle;
+
+        bannerPictureBox.Dock = DockStyle.Right;
+        bannerPictureBox.Width = 650;
+        bannerPictureBox.SizeMode = PictureBoxSizeMode.Zoom;
+
+        heroTitleLabel.Dock = DockStyle.Top;
+        heroTitleLabel.Height = 46;
+        heroTitleLabel.Font = new Font("Segoe UI Semibold", 22F);
+
+        heroSubtitleLabel.Dock = DockStyle.Top;
+        heroSubtitleLabel.Height = 28;
+        heroSubtitleLabel.Font = new Font("Segoe UI", 10F);
+
+        heroPanel.Controls.Add(heroSubtitleLabel);
+        heroPanel.Controls.Add(heroTitleLabel);
+        heroPanel.Controls.Add(bannerPictureBox);
+
+        statsPanel.Dock = DockStyle.Top;
+        statsPanel.Height = 130;
+        statsPanel.FlowDirection = FlowDirection.LeftToRight;
+        statsPanel.WrapContents = false;
+        statsPanel.AutoScroll = true;
+        statsPanel.Padding = new Padding(0, 14, 0, 10);
+
+        dashboardTablesPanel.Dock = DockStyle.Fill;
+        dashboardTablesPanel.ColumnCount = 2;
+        dashboardTablesPanel.RowCount = 1;
+        dashboardTablesPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
+        dashboardTablesPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
+
+        upcomingGroupBox.Text = "Ближайшие туры";
+        upcomingGroupBox.Dock = DockStyle.Fill;
+        upcomingGroupBox.Padding = new Padding(10);
+
+        bookingsGroupBox.Text = "Последние бронирования";
+        bookingsGroupBox.Dock = DockStyle.Fill;
+        bookingsGroupBox.Padding = new Padding(10);
+
+        upcomingGrid.Dock = DockStyle.Fill;
+        upcomingGrid.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill;
+
+        bookingsGrid.Dock = DockStyle.Fill;
+        bookingsGrid.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill;
+
+        upcomingGroupBox.Controls.Add(upcomingGrid);
+        bookingsGroupBox.Controls.Add(bookingsGrid);
+
+        dashboardTablesPanel.Controls.Add(upcomingGroupBox, 0, 0);
+        dashboardTablesPanel.Controls.Add(bookingsGroupBox, 1, 0);
+
+        contentPanel.Controls.Add(dashboardTablesPanel);
+        contentPanel.Controls.Add(statsPanel);
+        contentPanel.Controls.Add(heroPanel);
+
+        Controls.Add(contentPanel);
+        Controls.Add(menuPanel);
+
+        ResumeLayout(false);
+    }
+}

+ 251 - 0
TravelAgencyWinForms/Forms/MainForm.cs

@@ -0,0 +1,251 @@
+using Npgsql;
+using TravelAgencyWinForms.Data;
+using TravelAgencyWinForms.Models;
+using TravelAgencyWinForms.Services;
+using TravelAgencyWinForms.Utils;
+
+namespace TravelAgencyWinForms.Forms;
+
+public partial class MainForm : Form
+{
+    private Button? _activeMenuButton;
+
+    public MainForm()
+    {
+        InitializeComponent();
+        ApplyStyle();
+        LoadBanner();
+        BuildMenu();
+        LoadDashboard();
+        FormClosing += (_, _) => CurrentUser.Clear();
+    }
+
+    private void ApplyStyle()
+    {
+        AppTheme.ApplyForm(this);
+
+        menuPanel.BackColor = Color.FromArgb(76, 150, 138);
+        menuHeaderPanel.BackColor = Color.FromArgb(76, 150, 138);
+        menuItemsPanel.BackColor = Color.FromArgb(76, 150, 138);
+
+        appTitleLabel.ForeColor = Color.White;
+        modeLabel.ForeColor = Color.FromArgb(227, 247, 242);
+        loginLabel.ForeColor = Color.FromArgb(227, 247, 242);
+        userNameLabel.ForeColor = Color.White;
+
+        contentPanel.BackColor = AppTheme.Background;
+        heroPanel.BackColor = AppTheme.Surface;
+        heroTitleLabel.ForeColor = AppTheme.Text;
+        heroSubtitleLabel.ForeColor = AppTheme.Muted;
+
+        upcomingGroupBox.ForeColor = AppTheme.Text;
+        bookingsGroupBox.ForeColor = AppTheme.Text;
+
+        AppTheme.StyleGrid(upcomingGrid);
+        AppTheme.StyleGrid(bookingsGrid);
+
+        appTitleLabel.Text = "Турагентство";
+        modeLabel.Text = CurrentUser.IsAdmin ? "Панель администратора" : "Личный кабинет клиента";
+        loginLabel.Text = CurrentUser.Login;
+        userNameLabel.Text = string.IsNullOrWhiteSpace(CurrentUser.ClientName) ? CurrentUser.Login : CurrentUser.ClientName;
+        heroTitleLabel.Text = CurrentUser.IsAdmin ? "Панель управления" : "Личный кабинет";
+        heroSubtitleLabel.Text = CurrentUser.IsAdmin
+            ? "Быстрый доступ к данным, отчётам и журналу операций"
+            : "Просмотр туров, расписания и своих бронирований";
+    }
+
+    private void LoadBanner()
+    {
+        var image = AppTheme.LoadImage("main_dashboard_banner.png");
+        if (image != null)
+            bannerPictureBox.Image = image;
+    }
+
+    private void BuildMenu()
+    {
+        menuItemsPanel.Controls.Clear();
+
+        AddMenuButton("Главная", LoadDashboard, true);
+
+        if (CurrentUser.IsAdmin)
+        {
+            AddSection("Данные");
+            AddMenuButton("Клиенты", () => OpenTable(EntityConfigs.Clients, true));
+            AddMenuButton("Должности", () => OpenTable(EntityConfigs.Positions, true));
+            AddMenuButton("Сотрудники", () => OpenTable(EntityConfigs.Employees, true));
+            AddMenuButton("Города", () => OpenTable(EntityConfigs.Cities, true));
+            AddMenuButton("Туры", () => OpenTable(EntityConfigs.Tours, true));
+            AddMenuButton("Маршруты", () => OpenTable(EntityConfigs.TourCities, true));
+            AddMenuButton("Расписание", () => OpenTable(EntityConfigs.TourList, true));
+            AddMenuButton("Бронирования", () => OpenTable(EntityConfigs.Bookings, true));
+
+            AddSection("Отчёты");
+            AddMenuButton("Сводка", () => new ReportsForm().ShowDialog(this));
+            AddMenuButton("Просроченные", () => OpenTable(EntityConfigs.OverdueView, false));
+            AddMenuButton("Журнал", () => OpenTable(EntityConfigs.AuditLog, false));
+
+            AddSection("Сервис");
+            AddMenuButton("Пользователи", () => OpenTable(EntityConfigs.AppUsers, true));
+        }
+        else
+        {
+            AddSection("Мои данные");
+            AddMenuButton("Личные данные", () => OpenTable(EntityConfigs.ClientDetails, false));
+            AddMenuButton("Бронирования", () => OpenTable(EntityConfigs.UserBookings, false));
+            AddMenuButton("Просроченные", () => OpenTable(EntityConfigs.UserOverdue, false));
+
+            AddSection("Справочники");
+            AddMenuButton("Города", () => OpenTable(EntityConfigs.Cities, false));
+            AddMenuButton("Туры", () => OpenTable(EntityConfigs.Tours, false));
+            AddMenuButton("Расписание", () => OpenTable(EntityConfigs.TourScheduleView, false));
+            AddMenuButton("Отчёты", () => new ReportsForm().ShowDialog(this));
+        }
+
+        AddSection(string.Empty);
+        AddMenuButton("Выход", Close);
+    }
+
+    private void AddMenuButton(string text, Action action, bool selected = false)
+    {
+        var button = new Button
+        {
+            Text = text,
+            Width = 220,
+            Height = 38,
+            Margin = new Padding(0, 3, 0, 3)
+        };
+
+        AppTheme.StyleMenuButton(button);
+
+        if (selected)
+            SetActiveMenuButton(button);
+
+        button.Click += (_, _) =>
+        {
+            SetActiveMenuButton(button);
+            action();
+        };
+
+        menuItemsPanel.Controls.Add(button);
+    }
+
+    private void SetActiveMenuButton(Button button)
+    {
+        if (_activeMenuButton != null)
+            AppTheme.StyleMenuButton(_activeMenuButton);
+
+        _activeMenuButton = button;
+        button.BackColor = Color.FromArgb(98, 181, 166);
+    }
+
+    private void AddSection(string text)
+    {
+        if (string.IsNullOrWhiteSpace(text))
+        {
+            menuItemsPanel.Controls.Add(new Panel { Height = 12, Width = 220 });
+            return;
+        }
+
+        var label = new Label
+        {
+            Text = text.ToUpperInvariant(),
+            Width = 220,
+            Height = 26,
+            ForeColor = Color.FromArgb(216, 243, 237),
+            Font = new Font("Segoe UI Semibold", 8.5F),
+            Margin = new Padding(0, 14, 0, 2)
+        };
+
+        menuItemsPanel.Controls.Add(label);
+    }
+
+    private void OpenTable(EntityConfig config, bool canEdit)
+    {
+        using var form = new TableEditorForm(config, canEdit);
+        form.ShowDialog(this);
+        LoadDashboard();
+    }
+
+    private void LoadDashboard()
+    {
+        LoadStats();
+        LoadDashboardTables();
+    }
+
+    private void LoadStats()
+    {
+        statsPanel.Controls.Clear();
+
+        foreach (var card in LoadStatCards())
+            statsPanel.Controls.Add(card);
+    }
+
+    private IEnumerable<Control> LoadStatCards()
+    {
+        if (CurrentUser.IsAdmin)
+        {
+            yield return AppTheme.CreateCard("Клиенты", ScalarInt("SELECT COUNT(*) FROM clients"));
+            yield return AppTheme.CreateCard("Сотрудники", ScalarInt("SELECT COUNT(*) FROM employees"));
+            yield return AppTheme.CreateCard("Туры", ScalarInt("SELECT COUNT(*) FROM tours"));
+            yield return AppTheme.CreateCard("Бронирования", ScalarInt("SELECT COUNT(*) FROM bookings"));
+            yield return AppTheme.CreateCard("Просроченные", ScalarInt("SELECT COUNT(*) FROM v_overdue_client_bookings"));
+        }
+        else
+        {
+            yield return AppTheme.CreateCard("Бронирования", ScalarInt("SELECT COUNT(*) FROM bookings WHERE client_id = @client_id", new NpgsqlParameter("client_id", CurrentUser.ClientId ?? -1)));
+            yield return AppTheme.CreateCard("Просроченные", ScalarInt("SELECT COUNT(*) FROM v_overdue_client_bookings WHERE client_id = @client_id", new NpgsqlParameter("client_id", CurrentUser.ClientId ?? -1)));
+            yield return AppTheme.CreateCard("Туры", ScalarInt("SELECT COUNT(*) FROM tours"));
+            yield return AppTheme.CreateCard("Рейсы", ScalarInt("SELECT COUNT(*) FROM tour_list WHERE start_date >= CURRENT_DATE"), "будущие даты");
+        }
+    }
+
+    private void LoadDashboardTables()
+    {
+        upcomingGrid.DataSource = Db.Query(@"
+SELECT
+    tour_name AS ""Тур"",
+    start_date AS ""Дата начала"",
+    end_date AS ""Дата окончания"",
+    duration_days AS ""Длительность"",
+    price AS ""Стоимость""
+FROM v_tour_schedule
+WHERE start_date >= CURRENT_DATE
+ORDER BY start_date
+LIMIT 8;");
+
+        bookingsGrid.DataSource = CurrentUser.IsAdmin
+            ? Db.Query(@"
+SELECT
+    booking_number AS ""Номер"",
+    client_name AS ""Клиент"",
+    employee_name AS ""Сотрудник"",
+    tour_name AS ""Тур"",
+    booking_date AS ""Дата оформления"",
+    status AS ""Статус""
+FROM v_booking_details
+ORDER BY booking_date DESC, booking_id DESC
+LIMIT 8;")
+            : Db.Query(@"
+SELECT
+    booking_number AS ""Номер"",
+    client_name AS ""Клиент"",
+    tour_name AS ""Тур"",
+    booking_date AS ""Дата оформления"",
+    status AS ""Статус""
+FROM v_booking_details
+WHERE client_id = @client_id
+ORDER BY booking_date DESC, booking_id DESC
+LIMIT 8;", new NpgsqlParameter("client_id", CurrentUser.ClientId ?? -1));
+
+        AppTheme.StyleGrid(upcomingGrid);
+        AppTheme.StyleGrid(bookingsGrid);
+        PresentationHelper.ApplyHeaders(upcomingGrid);
+        PresentationHelper.ApplyHeaders(bookingsGrid);
+    }
+
+    private static string ScalarInt(string sql, params NpgsqlParameter[] parameters)
+    {
+        var result = Db.Scalar(sql, parameters);
+        return Convert.ToInt32(result ?? 0).ToString();
+    }
+}

+ 153 - 0
TravelAgencyWinForms/Forms/ReportsForm.Designer.cs

@@ -0,0 +1,153 @@
+namespace TravelAgencyWinForms.Forms;
+
+partial class ReportsForm
+{
+    private System.ComponentModel.IContainer components = null;
+    private Panel rootPanel;
+    private Panel headerPanel;
+    private PictureBox reportPictureBox;
+    private Label titleLabel;
+    private Label subtitleLabel;
+    private FlowLayoutPanel buttonsPanel;
+    private Button scheduleButton;
+    private Button bookingsButton;
+    private Button overdueButton;
+    private Button employeesButton;
+    private FlowLayoutPanel calculatedPanel;
+    private DataGridView reportGrid;
+    private Panel emptyPanel;
+    private TableLayoutPanel emptyLayout;
+    private PictureBox emptyPictureBox;
+    private Label emptyLabel;
+
+    protected override void Dispose(bool disposing)
+    {
+        if (disposing && components != null)
+            components.Dispose();
+
+        base.Dispose(disposing);
+    }
+
+    private void InitializeComponent()
+    {
+        components = new System.ComponentModel.Container();
+        rootPanel = new Panel();
+        headerPanel = new Panel();
+        reportPictureBox = new PictureBox();
+        titleLabel = new Label();
+        subtitleLabel = new Label();
+        buttonsPanel = new FlowLayoutPanel();
+        scheduleButton = new Button();
+        bookingsButton = new Button();
+        overdueButton = new Button();
+        employeesButton = new Button();
+        calculatedPanel = new FlowLayoutPanel();
+        reportGrid = new DataGridView();
+        emptyPanel = new Panel();
+        emptyLayout = new TableLayoutPanel();
+        emptyPictureBox = new PictureBox();
+        emptyLabel = new Label();
+
+        SuspendLayout();
+
+        Text = "Отчёты";
+        StartPosition = FormStartPosition.CenterParent;
+        WindowState = FormWindowState.Maximized;
+        MinimumSize = new Size(980, 640);
+
+        rootPanel.Dock = DockStyle.Fill;
+        rootPanel.Padding = new Padding(18);
+
+        headerPanel.Dock = DockStyle.Top;
+        headerPanel.Height = 92;
+        headerPanel.Padding = new Padding(18, 12, 18, 12);
+        headerPanel.BorderStyle = BorderStyle.FixedSingle;
+
+        reportPictureBox.Dock = DockStyle.Right;
+        reportPictureBox.Width = 170;
+        reportPictureBox.SizeMode = PictureBoxSizeMode.Zoom;
+
+        titleLabel.Dock = DockStyle.Top;
+        titleLabel.Height = 34;
+        titleLabel.Font = new Font("Segoe UI Semibold", 18F);
+
+        subtitleLabel.Dock = DockStyle.Top;
+        subtitleLabel.Height = 24;
+        subtitleLabel.Font = new Font("Segoe UI", 9.5F);
+
+        headerPanel.Controls.Add(subtitleLabel);
+        headerPanel.Controls.Add(titleLabel);
+        headerPanel.Controls.Add(reportPictureBox);
+
+        buttonsPanel.Dock = DockStyle.Top;
+        buttonsPanel.Height = 56;
+        buttonsPanel.Padding = new Padding(0, 6, 0, 6);
+
+        scheduleButton.Text = "Расписание";
+        scheduleButton.Width = 150;
+        scheduleButton.Height = 36;
+        scheduleButton.Click += ScheduleButton_Click;
+
+        bookingsButton.Text = "Бронирования";
+        bookingsButton.Width = 150;
+        bookingsButton.Height = 36;
+        bookingsButton.Click += BookingsButton_Click;
+
+        overdueButton.Text = "Просроченные";
+        overdueButton.Width = 150;
+        overdueButton.Height = 36;
+        overdueButton.Click += OverdueButton_Click;
+
+        employeesButton.Text = "Сотрудники";
+        employeesButton.Width = 150;
+        employeesButton.Height = 36;
+        employeesButton.Click += EmployeesButton_Click;
+
+        buttonsPanel.Controls.Add(scheduleButton);
+        buttonsPanel.Controls.Add(bookingsButton);
+        buttonsPanel.Controls.Add(overdueButton);
+        buttonsPanel.Controls.Add(employeesButton);
+
+        calculatedPanel.Dock = DockStyle.Top;
+        calculatedPanel.Height = 78;
+        calculatedPanel.WrapContents = false;
+        calculatedPanel.AutoScroll = true;
+        calculatedPanel.Padding = new Padding(0, 4, 0, 8);
+
+        reportGrid.Dock = DockStyle.Fill;
+        reportGrid.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.DisplayedCells;
+        reportGrid.SelectionChanged += ReportGrid_SelectionChanged;
+
+        emptyPanel.Dock = DockStyle.Fill;
+        emptyPanel.Visible = false;
+
+        emptyLayout.Dock = DockStyle.Fill;
+        emptyLayout.ColumnCount = 1;
+        emptyLayout.RowCount = 2;
+        emptyLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 75));
+        emptyLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 25));
+
+        emptyPictureBox.Anchor = AnchorStyles.None;
+        emptyPictureBox.Size = new Size(230, 230);
+        emptyPictureBox.SizeMode = PictureBoxSizeMode.Zoom;
+
+        emptyLabel.Dock = DockStyle.Fill;
+        emptyLabel.Text = "По этому запросу ничего не найдено";
+        emptyLabel.TextAlign = ContentAlignment.TopCenter;
+        emptyLabel.Font = new Font("Segoe UI", 11F);
+
+        emptyLayout.Controls.Add(emptyPictureBox, 0, 0);
+        emptyLayout.Controls.Add(emptyLabel, 0, 1);
+        emptyPanel.Controls.Add(emptyLayout);
+
+        rootPanel.Controls.Add(emptyPanel);
+        rootPanel.Controls.Add(reportGrid);
+        rootPanel.Controls.Add(calculatedPanel);
+        rootPanel.Controls.Add(buttonsPanel);
+        rootPanel.Controls.Add(headerPanel);
+
+        Controls.Add(rootPanel);
+
+        ResumeLayout(false);
+    }
+}

+ 210 - 0
TravelAgencyWinForms/Forms/ReportsForm.cs

@@ -0,0 +1,210 @@
+using System.Data;
+using Npgsql;
+using TravelAgencyWinForms.Data;
+using TravelAgencyWinForms.Models;
+using TravelAgencyWinForms.Utils;
+
+namespace TravelAgencyWinForms.Forms;
+
+public partial class ReportsForm : Form
+{
+    private readonly List<Button> _reportButtons = new();
+    private DataTable _currentTable = new();
+
+    public ReportsForm()
+    {
+        InitializeComponent();
+        ApplyStyle();
+        LoadIllustration();
+        RegisterButtons();
+        Load += (_, _) => LoadBookingsReport();
+    }
+
+    private void ApplyStyle()
+    {
+        AppTheme.ApplyForm(this);
+        headerPanel.BackColor = AppTheme.Surface;
+        titleLabel.ForeColor = AppTheme.Text;
+        subtitleLabel.ForeColor = AppTheme.Muted;
+
+        AppTheme.StyleGrid(reportGrid);
+        AppTheme.StyleSelectedButton(bookingsButton);
+        AppTheme.StyleSecondaryButton(scheduleButton);
+        AppTheme.StyleSecondaryButton(overdueButton);
+        AppTheme.StyleSecondaryButton(employeesButton);
+
+        employeesButton.Visible = CurrentUser.IsAdmin;
+    }
+
+    private void LoadIllustration()
+    {
+        var image = AppTheme.LoadImage("booking_report.png");
+        if (image != null)
+            reportPictureBox.Image = image;
+
+        var emptyImage = AppTheme.LoadImage("empty_results.png");
+        if (emptyImage != null)
+            emptyPictureBox.Image = emptyImage;
+    }
+
+    private void RegisterButtons()
+    {
+        _reportButtons.Clear();
+        _reportButtons.Add(scheduleButton);
+        _reportButtons.Add(bookingsButton);
+        _reportButtons.Add(overdueButton);
+
+        if (CurrentUser.IsAdmin)
+            _reportButtons.Add(employeesButton);
+    }
+
+    private void ScheduleButton_Click(object? sender, EventArgs e)
+    {
+        SetActiveButton(scheduleButton);
+        LoadScheduleReport();
+    }
+
+    private void BookingsButton_Click(object? sender, EventArgs e)
+    {
+        SetActiveButton(bookingsButton);
+        LoadBookingsReport();
+    }
+
+    private void OverdueButton_Click(object? sender, EventArgs e)
+    {
+        SetActiveButton(overdueButton);
+        LoadOverdueReport();
+    }
+
+    private void EmployeesButton_Click(object? sender, EventArgs e)
+    {
+        SetActiveButton(employeesButton);
+        LoadEmployeesReport();
+    }
+
+    private void SetActiveButton(Button selectedButton)
+    {
+        foreach (var button in _reportButtons)
+        {
+            if (button == selectedButton)
+                AppTheme.StyleSelectedButton(button);
+            else
+                AppTheme.StyleSecondaryButton(button);
+        }
+    }
+
+    private void LoadScheduleReport()
+    {
+        titleLabel.Text = "Расписание туров";
+        subtitleLabel.Text = "График выездов и длительность поездок";
+
+        BindReport(Db.Query(@"
+SELECT
+    tour_name AS ""Тур"",
+    cities AS ""Города"",
+    start_date AS ""Дата начала"",
+    end_date AS ""Дата окончания"",
+    duration_days AS duration_days,
+    price AS ""Стоимость""
+FROM v_tour_schedule
+ORDER BY start_date, tour_name;"));
+    }
+
+    private void LoadBookingsReport()
+    {
+        titleLabel.Text = CurrentUser.IsAdmin ? "Бронирования" : "Мои бронирования";
+        subtitleLabel.Text = CurrentUser.IsAdmin ? "Полный список оформленных бронирований" : "Бронирования, связанные с текущим клиентом";
+
+        var table = CurrentUser.IsAdmin
+            ? Db.Query("SELECT * FROM v_booking_details ORDER BY booking_date DESC, booking_id DESC;")
+            : Db.Query("SELECT * FROM v_booking_details WHERE client_id = @client_id ORDER BY booking_date DESC, booking_id DESC;", new NpgsqlParameter("client_id", CurrentUser.ClientId ?? -1));
+
+        BindReport(table);
+    }
+
+    private void LoadOverdueReport()
+    {
+        titleLabel.Text = CurrentUser.IsAdmin ? "Просроченные бронирования" : "Мои просроченные бронирования";
+        subtitleLabel.Text = "Туры с прошедшей датой начала и незавершённым статусом";
+
+        var table = CurrentUser.IsAdmin
+            ? Db.Query("SELECT * FROM v_overdue_client_bookings ORDER BY days_overdue DESC, booking_id DESC;")
+            : Db.Query("SELECT * FROM v_overdue_client_bookings WHERE client_id = @client_id ORDER BY days_overdue DESC, booking_id DESC;", new NpgsqlParameter("client_id", CurrentUser.ClientId ?? -1));
+
+        BindReport(table);
+    }
+
+    private void LoadEmployeesReport()
+    {
+        titleLabel.Text = "Сотрудники";
+        subtitleLabel.Text = "Возраст и основные сведения по сотрудникам";
+        BindReport(Db.Query("SELECT * FROM v_employee_details ORDER BY employee_name;"));
+    }
+
+    private void BindReport(DataTable table)
+    {
+        _currentTable = table;
+        reportGrid.DataSource = _currentTable;
+        UpdateEmptyState(_currentTable.Rows.Count == 0);
+        PresentationHelper.ApplyHeaders(reportGrid);
+        PresentationHelper.HideReportColumns(reportGrid);
+        HideCalculatedColumns();
+        UpdateCalculatedPanel();
+
+        if (reportGrid.Rows.Count > 0 && reportGrid.Columns.Cast<DataGridViewColumn>().Any(c => c.Visible))
+        {
+            var firstVisible = reportGrid.Columns.Cast<DataGridViewColumn>().First(c => c.Visible);
+            reportGrid.CurrentCell = reportGrid.Rows[0].Cells[firstVisible.Index];
+        }
+    }
+
+    private void UpdateEmptyState(bool isEmpty)
+    {
+        reportGrid.Visible = !isEmpty;
+        emptyPanel.Visible = isEmpty;
+    }
+
+    private void HideCalculatedColumns()
+    {
+        foreach (DataGridViewColumn column in reportGrid.Columns)
+            if (CalculatedFields.IsCalculated(column.Name))
+                column.Visible = false;
+    }
+
+    private void UpdateCalculatedPanel()
+    {
+        calculatedPanel.Controls.Clear();
+        var calculatedColumns = CalculatedFields.GetColumns(_currentTable);
+
+        if (calculatedColumns.Count == 0)
+        {
+            calculatedPanel.Visible = false;
+            return;
+        }
+
+        calculatedPanel.Visible = true;
+        var selectedRow = GetSelectedRow();
+
+        foreach (var columnName in calculatedColumns)
+        {
+            var value = "—";
+            if (selectedRow != null && selectedRow.Table.Columns.Contains(columnName))
+                value = CalculatedFields.FormatValue(columnName, selectedRow[columnName]);
+
+            calculatedPanel.Controls.Add(AppTheme.CreateCard(CalculatedFields.GetLabel(columnName), value));
+        }
+    }
+
+    private DataRow? GetSelectedRow()
+    {
+        if (reportGrid.CurrentRow?.DataBoundItem is DataRowView view)
+            return view.Row;
+
+        return null;
+    }
+
+    private void ReportGrid_SelectionChanged(object? sender, EventArgs e)
+    {
+        UpdateCalculatedPanel();
+    }
+}

+ 164 - 0
TravelAgencyWinForms/Forms/TableEditorForm.Designer.cs

@@ -0,0 +1,164 @@
+namespace TravelAgencyWinForms.Forms;
+
+partial class TableEditorForm
+{
+    private System.ComponentModel.IContainer components = null;
+    private Panel rootPanel;
+    private Panel headerPanel;
+    private Label titleLabel;
+    private Label subtitleLabel;
+    private FlowLayoutPanel actionsPanel;
+    private Button refreshButton;
+    private Button addButton;
+    private Button editButton;
+    private Button deleteButton;
+    private Label searchLabel;
+    private TextBox searchTextBox;
+    private FlowLayoutPanel calculatedPanel;
+    private Label infoLabel;
+    private DataGridView dataGrid;
+    private Panel emptyPanel;
+    private TableLayoutPanel emptyLayout;
+    private PictureBox emptyPictureBox;
+    private Label emptyLabel;
+
+    protected override void Dispose(bool disposing)
+    {
+        if (disposing && components != null)
+            components.Dispose();
+
+        base.Dispose(disposing);
+    }
+
+    private void InitializeComponent()
+    {
+        components = new System.ComponentModel.Container();
+        rootPanel = new Panel();
+        headerPanel = new Panel();
+        titleLabel = new Label();
+        subtitleLabel = new Label();
+        actionsPanel = new FlowLayoutPanel();
+        refreshButton = new Button();
+        addButton = new Button();
+        editButton = new Button();
+        deleteButton = new Button();
+        searchLabel = new Label();
+        searchTextBox = new TextBox();
+        calculatedPanel = new FlowLayoutPanel();
+        infoLabel = new Label();
+        dataGrid = new DataGridView();
+        emptyPanel = new Panel();
+        emptyLayout = new TableLayoutPanel();
+        emptyPictureBox = new PictureBox();
+        emptyLabel = new Label();
+
+        SuspendLayout();
+
+        StartPosition = FormStartPosition.CenterParent;
+        WindowState = FormWindowState.Maximized;
+        MinimumSize = new Size(1000, 650);
+
+        rootPanel.Dock = DockStyle.Fill;
+        rootPanel.Padding = new Padding(18);
+
+        headerPanel.Dock = DockStyle.Top;
+        headerPanel.Height = 86;
+        headerPanel.Padding = new Padding(20, 16, 20, 16);
+        headerPanel.BorderStyle = BorderStyle.FixedSingle;
+
+        titleLabel.Dock = DockStyle.Top;
+        titleLabel.Height = 30;
+        titleLabel.Font = new Font("Segoe UI Semibold", 18F);
+
+        subtitleLabel.Dock = DockStyle.Fill;
+        subtitleLabel.Font = new Font("Segoe UI", 9.5F);
+
+        headerPanel.Controls.Add(subtitleLabel);
+        headerPanel.Controls.Add(titleLabel);
+
+        actionsPanel.Dock = DockStyle.Top;
+        actionsPanel.Height = 52;
+        actionsPanel.Padding = new Padding(0, 6, 0, 6);
+
+        refreshButton.Text = "Обновить";
+        refreshButton.Width = 120;
+        refreshButton.Height = 36;
+        refreshButton.Click += RefreshButton_Click;
+
+        addButton.Text = "Добавить";
+        addButton.Width = 120;
+        addButton.Height = 36;
+        addButton.Click += AddButton_Click;
+
+        editButton.Text = "Изменить";
+        editButton.Width = 120;
+        editButton.Height = 36;
+        editButton.Click += EditButton_Click;
+
+        deleteButton.Text = "Удалить";
+        deleteButton.Width = 120;
+        deleteButton.Height = 36;
+        deleteButton.Click += DeleteButton_Click;
+
+        searchLabel.Text = "Поиск";
+        searchLabel.AutoSize = true;
+        searchLabel.Padding = new Padding(12, 9, 0, 0);
+
+        searchTextBox.Width = 220;
+        searchTextBox.TextChanged += SearchTextBox_TextChanged;
+
+        actionsPanel.Controls.Add(refreshButton);
+        actionsPanel.Controls.Add(addButton);
+        actionsPanel.Controls.Add(editButton);
+        actionsPanel.Controls.Add(deleteButton);
+        actionsPanel.Controls.Add(searchLabel);
+        actionsPanel.Controls.Add(searchTextBox);
+
+        calculatedPanel.Dock = DockStyle.Top;
+        calculatedPanel.Height = 78;
+        calculatedPanel.WrapContents = false;
+        calculatedPanel.AutoScroll = true;
+        calculatedPanel.Visible = false;
+
+        infoLabel.Dock = DockStyle.Bottom;
+        infoLabel.Height = 24;
+        infoLabel.TextAlign = ContentAlignment.MiddleLeft;
+
+        dataGrid.Dock = DockStyle.Fill;
+        dataGrid.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.DisplayedCells;
+        dataGrid.SelectionChanged += DataGrid_SelectionChanged;
+
+        emptyPanel.Dock = DockStyle.Fill;
+        emptyPanel.Visible = false;
+
+        emptyLayout.Dock = DockStyle.Fill;
+        emptyLayout.ColumnCount = 1;
+        emptyLayout.RowCount = 2;
+        emptyLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 75));
+        emptyLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 25));
+
+        emptyPictureBox.Anchor = AnchorStyles.None;
+        emptyPictureBox.Size = new Size(220, 220);
+        emptyPictureBox.SizeMode = PictureBoxSizeMode.Zoom;
+
+        emptyLabel.Dock = DockStyle.Fill;
+        emptyLabel.Text = "Данные не найдены";
+        emptyLabel.TextAlign = ContentAlignment.TopCenter;
+        emptyLabel.Font = new Font("Segoe UI", 11F);
+
+        emptyLayout.Controls.Add(emptyPictureBox, 0, 0);
+        emptyLayout.Controls.Add(emptyLabel, 0, 1);
+        emptyPanel.Controls.Add(emptyLayout);
+
+        rootPanel.Controls.Add(emptyPanel);
+        rootPanel.Controls.Add(dataGrid);
+        rootPanel.Controls.Add(infoLabel);
+        rootPanel.Controls.Add(calculatedPanel);
+        rootPanel.Controls.Add(actionsPanel);
+        rootPanel.Controls.Add(headerPanel);
+
+        Controls.Add(rootPanel);
+
+        ResumeLayout(false);
+    }
+}

+ 408 - 0
TravelAgencyWinForms/Forms/TableEditorForm.cs

@@ -0,0 +1,408 @@
+using System.Data;
+using System.Text;
+using Npgsql;
+using TravelAgencyWinForms.Data;
+using TravelAgencyWinForms.Models;
+using TravelAgencyWinForms.Utils;
+
+namespace TravelAgencyWinForms.Forms;
+
+public partial class TableEditorForm : Form
+{
+    private readonly EntityConfig _config;
+    private readonly bool _canEdit;
+    private DataTable _sourceTable = new();
+
+    public TableEditorForm(EntityConfig config, bool canEdit)
+    {
+        _config = config;
+        _canEdit = canEdit && !config.ReadOnly;
+
+        InitializeComponent();
+        ApplyStyle();
+        ConfigureForm();
+        Load += (_, _) => LoadData();
+    }
+
+    private void ApplyStyle()
+    {
+        AppTheme.ApplyForm(this);
+        AppTheme.StyleGrid(dataGrid);
+        AppTheme.StyleTextBox(searchTextBox);
+        AppTheme.StylePrimaryButton(refreshButton);
+        AppTheme.StyleSecondaryButton(addButton);
+        AppTheme.StyleSecondaryButton(editButton);
+        AppTheme.StyleDangerButton(deleteButton);
+
+        headerPanel.BackColor = AppTheme.Surface;
+        titleLabel.ForeColor = AppTheme.Text;
+        subtitleLabel.ForeColor = AppTheme.Muted;
+        searchLabel.ForeColor = AppTheme.Muted;
+        infoLabel.ForeColor = AppTheme.Muted;
+        emptyLabel.ForeColor = AppTheme.Muted;
+
+        var image = AppTheme.LoadImage("empty_results.png");
+        if (image != null)
+            emptyPictureBox.Image = image;
+    }
+
+    private void ConfigureForm()
+    {
+        Text = _config.Title;
+        titleLabel.Text = _config.Title;
+        subtitleLabel.Text = _config.ReadOnly ? "Просмотр данных" : "Просмотр и редактирование данных";
+
+        addButton.Enabled = _canEdit;
+        editButton.Enabled = _canEdit && _config.Fields.Any(f => !f.IsKey);
+        deleteButton.Enabled = _canEdit;
+    }
+
+    private void RefreshButton_Click(object? sender, EventArgs e)
+    {
+        LoadData();
+    }
+
+    private void AddButton_Click(object? sender, EventArgs e)
+    {
+        AddRecord();
+    }
+
+    private void EditButton_Click(object? sender, EventArgs e)
+    {
+        EditRecord();
+    }
+
+    private void DeleteButton_Click(object? sender, EventArgs e)
+    {
+        DeleteRecord();
+    }
+
+    private void SearchTextBox_TextChanged(object? sender, EventArgs e)
+    {
+        ApplySearch();
+    }
+
+    private void DataGrid_SelectionChanged(object? sender, EventArgs e)
+    {
+        UpdateCalculatedPanel();
+    }
+
+    private void LoadData()
+    {
+        Cursor = Cursors.WaitCursor;
+        try
+        {
+            var (sql, parameters) = BuildSelectSql();
+            _sourceTable = Db.Query(sql, parameters.ToArray());
+            dataGrid.DataSource = _sourceTable;
+            ApplyGridPresentation();
+            UpdateEmptyState(_sourceTable.Rows.Count == 0);
+            UpdateCalculatedPanel();
+            infoLabel.Text = $"Записей: {_sourceTable.Rows.Count}";
+        }
+        catch (Exception ex)
+        {
+            MessageBox.Show(ErrorHelper.ToUserMessage(ex), "Загрузка", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+        }
+        finally
+        {
+            Cursor = Cursors.Default;
+        }
+    }
+
+    private (string Sql, List<NpgsqlParameter> Parameters) BuildSelectSql()
+    {
+        var parameters = new List<NpgsqlParameter>();
+        string selectPart;
+
+        if (_config.Fields.Count == 0)
+        {
+            selectPart = "*";
+        }
+        else
+        {
+            selectPart = string.Join(", ", _config.GridFields.Select(f => f.Name == "role" && _config.TableName == "app_users" ? "role::text AS role" : Q(f.Name)));
+        }
+
+        var sb = new StringBuilder();
+        sb.Append("SELECT ").Append(selectPart).Append(" FROM ").Append(Q(_config.SourceName));
+
+        var where = new List<string>();
+        if (_config.RestrictByCurrentEmployee && !string.IsNullOrWhiteSpace(_config.RestrictColumn))
+        {
+            if (CurrentUser.ClientId == null)
+            {
+                where.Add("1 = 0");
+            }
+            else
+            {
+                where.Add(Q(_config.RestrictColumn) + " = @client_id");
+                parameters.Add(new NpgsqlParameter("client_id", CurrentUser.ClientId.Value));
+            }
+        }
+
+        if (where.Count > 0)
+            sb.Append(" WHERE ").Append(string.Join(" AND ", where));
+
+        if (!string.IsNullOrWhiteSpace(_config.OrderBy))
+            sb.Append(" ORDER BY ").Append(_config.OrderBy);
+
+        return (sb.ToString(), parameters);
+    }
+
+    private void ApplyGridPresentation()
+    {
+        PresentationHelper.ApplyHeaders(dataGrid);
+
+        if (_config.Fields.Count == 0 || _config.ReadOnly)
+            PresentationHelper.HideViewColumns(dataGrid);
+
+        foreach (DataGridViewColumn column in dataGrid.Columns)
+            if (CalculatedFields.IsCalculated(column.Name))
+                column.Visible = false;
+
+        if (dataGrid.Rows.Count > 0 && dataGrid.Columns.Cast<DataGridViewColumn>().Any(c => c.Visible))
+        {
+            var firstVisible = dataGrid.Columns.Cast<DataGridViewColumn>().First(c => c.Visible);
+            dataGrid.CurrentCell = dataGrid.Rows[0].Cells[firstVisible.Index];
+        }
+    }
+
+    private void UpdateEmptyState(bool isEmpty)
+    {
+        dataGrid.Visible = !isEmpty;
+        emptyPanel.Visible = isEmpty;
+    }
+
+    private void UpdateCalculatedPanel()
+    {
+        calculatedPanel.Controls.Clear();
+        var table = GetCurrentGridTable();
+        var calculatedColumns = CalculatedFields.GetColumns(table);
+
+        if (calculatedColumns.Count == 0)
+        {
+            calculatedPanel.Visible = false;
+            return;
+        }
+
+        calculatedPanel.Visible = true;
+        var selectedRow = GetSelectedRow();
+
+        foreach (var columnName in calculatedColumns)
+        {
+            var value = "—";
+            if (selectedRow != null && selectedRow.Table.Columns.Contains(columnName))
+                value = CalculatedFields.FormatValue(columnName, selectedRow[columnName]);
+
+            calculatedPanel.Controls.Add(AppTheme.CreateCard(CalculatedFields.GetLabel(columnName), value));
+        }
+    }
+
+    private DataTable GetCurrentGridTable()
+    {
+        return dataGrid.DataSource switch
+        {
+            DataTable table => table,
+            DataView view => view.ToTable(),
+            _ => _sourceTable
+        };
+    }
+
+    private void ApplySearch()
+    {
+        if (_sourceTable.Rows.Count == 0)
+            return;
+
+        var search = searchTextBox.Text.Trim().ToLowerInvariant();
+        if (string.IsNullOrWhiteSpace(search))
+        {
+            dataGrid.DataSource = _sourceTable;
+            ApplyGridPresentation();
+            UpdateEmptyState(_sourceTable.Rows.Count == 0);
+            UpdateCalculatedPanel();
+            infoLabel.Text = $"Записей: {_sourceTable.Rows.Count}";
+            return;
+        }
+
+        var filtered = _sourceTable.Clone();
+        foreach (DataRow row in _sourceTable.Rows)
+        {
+            var haystack = string.Join(" ", row.ItemArray.Select(v => v?.ToString() ?? string.Empty)).ToLowerInvariant();
+            if (haystack.Contains(search))
+                filtered.ImportRow(row);
+        }
+
+        dataGrid.DataSource = filtered;
+        ApplyGridPresentation();
+        UpdateEmptyState(filtered.Rows.Count == 0);
+        UpdateCalculatedPanel();
+        infoLabel.Text = $"Найдено: {filtered.Rows.Count}";
+    }
+
+    private DataRow? GetSelectedRow()
+    {
+        if (dataGrid.CurrentRow?.DataBoundItem is DataRowView view)
+            return view.Row;
+
+        return null;
+    }
+
+    private void AddRecord()
+    {
+        using var dialog = new EntityEditDialog(_config);
+        if (dialog.ShowDialog(this) != DialogResult.OK)
+            return;
+
+        try
+        {
+            Db.ResetIdentitySequences();
+            var (sql, parameters) = BuildInsertSql(dialog.Values, dialog.PasswordWasEntered);
+            Db.SetAppContext(CurrentUser.Login);
+            Db.Execute(sql, parameters.ToArray());
+            LoadData();
+        }
+        catch (Exception ex)
+        {
+            MessageBox.Show(ErrorHelper.ToUserMessage(ex), "Добавление", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+        }
+    }
+
+    private void EditRecord()
+    {
+        var row = GetSelectedRow();
+        if (row == null)
+        {
+            MessageBox.Show("Сначала выберите запись.", "Изменение", MessageBoxButtons.OK, MessageBoxIcon.Information);
+            return;
+        }
+
+        using var dialog = new EntityEditDialog(_config, row);
+        if (dialog.ShowDialog(this) != DialogResult.OK)
+            return;
+
+        try
+        {
+            var (sql, parameters) = BuildUpdateSql(dialog.Values, dialog.PasswordWasEntered, row);
+            Db.SetAppContext(CurrentUser.Login);
+            Db.Execute(sql, parameters.ToArray());
+            LoadData();
+        }
+        catch (Exception ex)
+        {
+            MessageBox.Show(ErrorHelper.ToUserMessage(ex), "Изменение", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+        }
+    }
+
+    private void DeleteRecord()
+    {
+        var row = GetSelectedRow();
+        if (row == null)
+        {
+            MessageBox.Show("Сначала выберите запись.", "Удаление", MessageBoxButtons.OK, MessageBoxIcon.Information);
+            return;
+        }
+
+        if (MessageBox.Show("Удалить выбранную запись?", "Удаление", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes)
+            return;
+
+        try
+        {
+            var (sql, parameters) = BuildDeleteSql(row);
+            Db.SetAppContext(CurrentUser.Login);
+            Db.Execute(sql, parameters.ToArray());
+            LoadData();
+        }
+        catch (Exception ex)
+        {
+            MessageBox.Show(ErrorHelper.ToUserMessage(ex), "Удаление", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+        }
+    }
+
+    private (string Sql, List<NpgsqlParameter> Parameters) BuildInsertSql(Dictionary<string, object?> values, bool passwordWasEntered)
+    {
+        var insertFields = _config.Fields
+            .Where(f => !(f.IsKey && f.AutoIdentity))
+            .Where(f => f.Kind != FieldKind.Password || passwordWasEntered)
+            .ToList();
+
+        var columns = new List<string>();
+        var valueSql = new List<string>();
+        var parameters = new List<NpgsqlParameter>();
+
+        foreach (var field in insertFields)
+        {
+            columns.Add(Q(field.Name));
+            valueSql.Add(SqlValueExpression(field));
+            parameters.Add(MakeParameter(field.Name, values.GetValueOrDefault(field.Name)));
+        }
+
+        var sql = $"INSERT INTO {Q(_config.TableName)} ({string.Join(", ", columns)}) VALUES ({string.Join(", ", valueSql)});";
+        return (sql, parameters);
+    }
+
+    private (string Sql, List<NpgsqlParameter> Parameters) BuildUpdateSql(Dictionary<string, object?> values, bool passwordWasEntered, DataRow originalRow)
+    {
+        var updateFields = _config.Fields
+            .Where(f => !f.IsKey)
+            .Where(f => f.Kind != FieldKind.Password || passwordWasEntered)
+            .ToList();
+
+        var setParts = new List<string>();
+        var parameters = new List<NpgsqlParameter>();
+
+        foreach (var field in updateFields)
+        {
+            setParts.Add($"{Q(field.Name)} = {SqlValueExpression(field)}");
+            parameters.Add(MakeParameter(field.Name, values.GetValueOrDefault(field.Name)));
+        }
+
+        var whereParts = new List<string>();
+        foreach (var key in _config.KeyFields)
+        {
+            var paramName = "key_" + key.Name;
+            whereParts.Add($"{Q(key.Name)} = @{paramName}");
+            parameters.Add(MakeParameter(paramName, originalRow[key.Name]));
+        }
+
+        var sql = $"UPDATE {Q(_config.TableName)} SET {string.Join(", ", setParts)} WHERE {string.Join(" AND ", whereParts)};";
+        return (sql, parameters);
+    }
+
+    private (string Sql, List<NpgsqlParameter> Parameters) BuildDeleteSql(DataRow row)
+    {
+        var whereParts = new List<string>();
+        var parameters = new List<NpgsqlParameter>();
+
+        foreach (var key in _config.KeyFields)
+        {
+            var paramName = "key_" + key.Name;
+            whereParts.Add($"{Q(key.Name)} = @{paramName}");
+            parameters.Add(MakeParameter(paramName, row[key.Name]));
+        }
+
+        var sql = $"DELETE FROM {Q(_config.TableName)} WHERE {string.Join(" AND ", whereParts)};";
+        return (sql, parameters);
+    }
+
+    private string SqlValueExpression(FieldConfig field)
+    {
+        if (_config.TableName == "app_users" && field.Name == "password_hash")
+            return "fn_hash_password(@password_hash)";
+
+        if (_config.TableName == "app_users" && field.Name == "role")
+            return "@role::app_role";
+
+        return "@" + field.Name;
+    }
+
+    private static NpgsqlParameter MakeParameter(string name, object? value)
+    {
+        return new NpgsqlParameter(name, value ?? DBNull.Value);
+    }
+
+    private static string Q(string identifier)
+    {
+        return "\"" + identifier.Replace("\"", "\"\"") + "\"";
+    }
+}

+ 22 - 0
TravelAgencyWinForms/Models/CurrentUser.cs

@@ -0,0 +1,22 @@
+namespace TravelAgencyWinForms.Models;
+
+public static class CurrentUser
+{
+    public static int UserId { get; set; }
+    public static string Login { get; set; } = string.Empty;
+    public static string Role { get; set; } = string.Empty;
+    public static int? ClientId { get; set; }
+    public static string ClientName { get; set; } = string.Empty;
+
+    public static bool IsAdmin => string.Equals(Role, "admin", StringComparison.OrdinalIgnoreCase);
+    public static bool IsUser => string.Equals(Role, "user", StringComparison.OrdinalIgnoreCase);
+
+    public static void Clear()
+    {
+        UserId = 0;
+        Login = string.Empty;
+        Role = string.Empty;
+        ClientId = null;
+        ClientName = string.Empty;
+    }
+}

+ 19 - 0
TravelAgencyWinForms/Models/EntityConfig.cs

@@ -0,0 +1,19 @@
+namespace TravelAgencyWinForms.Models;
+
+public sealed class EntityConfig
+{
+    public string Title { get; init; } = string.Empty;
+    public string TableName { get; init; } = string.Empty;
+    public string? ViewName { get; init; }
+    public string OrderBy { get; init; } = string.Empty;
+    public bool ReadOnly { get; init; }
+    public bool RestrictByCurrentEmployee { get; init; }
+    public string? RestrictColumn { get; init; }
+    public List<FieldConfig> Fields { get; init; } = new();
+
+    public IEnumerable<FieldConfig> KeyFields => Fields.Where(f => f.IsKey);
+    public IEnumerable<FieldConfig> GridFields => Fields.Where(f => f.ShowInGrid);
+    public IEnumerable<FieldConfig> EditableFields => Fields.Where(f => !(f.IsKey && f.AutoIdentity));
+
+    public string SourceName => ViewName ?? TableName;
+}

+ 29 - 0
TravelAgencyWinForms/Models/FieldConfig.cs

@@ -0,0 +1,29 @@
+namespace TravelAgencyWinForms.Models;
+
+public enum FieldKind
+{
+    Text,
+    Integer,
+    Decimal,
+    Date,
+    Boolean,
+    Lookup,
+    Enum,
+    Password
+}
+
+public sealed class FieldConfig
+{
+    public string Name { get; init; } = string.Empty;
+    public string Label { get; init; } = string.Empty;
+    public FieldKind Kind { get; init; } = FieldKind.Text;
+    public bool Required { get; init; }
+    public bool IsKey { get; init; }
+    public bool AutoIdentity { get; init; }
+    public bool ShowInGrid { get; init; } = true;
+    public bool ReadOnlyOnEdit { get; init; }
+    public string? LookupSql { get; init; }
+    public string LookupValueColumn { get; init; } = "id";
+    public string LookupDisplayColumn { get; init; } = "name";
+    public string[] Options { get; init; } = Array.Empty<string>();
+}

+ 16 - 0
TravelAgencyWinForms/Program.cs

@@ -0,0 +1,16 @@
+namespace TravelAgencyWinForms;
+
+internal static class Program
+{
+    [STAThread]
+    private static void Main()
+    {
+        ApplicationConfiguration.Initialize();
+
+        using var loginForm = new Forms.LoginForm();
+        if (loginForm.ShowDialog() == DialogResult.OK)
+        {
+            Application.Run(new Forms.MainForm());
+        }
+    }
+}

+ 67 - 0
TravelAgencyWinForms/Services/AuthService.cs

@@ -0,0 +1,67 @@
+using Npgsql;
+using TravelAgencyWinForms.Data;
+using TravelAgencyWinForms.Models;
+using TravelAgencyWinForms.Utils;
+
+namespace TravelAgencyWinForms.Services;
+
+public static class AuthService
+{
+    public static bool Login(string login, string password, out string error)
+    {
+        error = string.Empty;
+
+        try
+        {
+            var table = Db.Query(
+                "SELECT * FROM fn_authenticate_user(@login, @password);",
+                new NpgsqlParameter("login", login.Trim()),
+                new NpgsqlParameter("password", password));
+
+            if (table.Rows.Count == 0)
+            {
+                error = "Неверный логин или пароль, либо учётная запись отключена.";
+                return false;
+            }
+
+            var row = table.Rows[0];
+            CurrentUser.UserId = Convert.ToInt32(row["user_id"]);
+            CurrentUser.Login = Convert.ToString(row["login"]) ?? string.Empty;
+            CurrentUser.Role = Convert.ToString(row["role"]) ?? string.Empty;
+
+            if (table.Columns.Contains("client_id"))
+                CurrentUser.ClientId = row["client_id"] == DBNull.Value ? null : Convert.ToInt32(row["client_id"]);
+            else if (table.Columns.Contains("employee_id"))
+                CurrentUser.ClientId = row["employee_id"] == DBNull.Value ? null : Convert.ToInt32(row["employee_id"]);
+            else
+                CurrentUser.ClientId = null;
+
+            if (table.Columns.Contains("client_name"))
+            {
+                CurrentUser.ClientName = Convert.ToString(row["client_name"]) ?? string.Empty;
+            }
+            else if (CurrentUser.ClientId != null)
+            {
+                var clientTable = Db.Query(
+                    "SELECT concat_ws(' ', last_name, first_name, middle_name) AS client_name FROM clients WHERE client_id = @client_id;",
+                    new NpgsqlParameter("client_id", CurrentUser.ClientId.Value));
+
+                CurrentUser.ClientName = clientTable.Rows.Count > 0
+                    ? Convert.ToString(clientTable.Rows[0]["client_name"]) ?? string.Empty
+                    : string.Empty;
+            }
+            else
+            {
+                CurrentUser.ClientName = string.Empty;
+            }
+
+            Db.SetAppContext(CurrentUser.Login);
+            return true;
+        }
+        catch (Exception ex)
+        {
+            error = "Ошибка авторизации: " + ErrorHelper.ToUserMessage(ex);
+            return false;
+        }
+    }
+}

+ 252 - 0
TravelAgencyWinForms/Services/EntityConfigs.cs

@@ -0,0 +1,252 @@
+using TravelAgencyWinForms.Models;
+
+namespace TravelAgencyWinForms.Services;
+
+public static class EntityConfigs
+{
+    public static EntityConfig Clients => new()
+    {
+        Title = "Клиенты",
+        TableName = "clients",
+        OrderBy = "client_id",
+        Fields = new()
+        {
+            Id("client_id", "ID"),
+            Text("first_name", "Имя", true),
+            Text("last_name", "Фамилия", true),
+            Text("middle_name", "Отчество"),
+            Text("passport_data", "Паспорт", true),
+            Text("phone", "Телефон"),
+            Text("email", "Email"),
+            Date("registration_date", "Дата регистрации", true)
+        }
+    };
+
+    public static EntityConfig Positions => new()
+    {
+        Title = "Должности",
+        TableName = "positions",
+        OrderBy = "position_id",
+        Fields = new()
+        {
+            Id("position_id", "ID"),
+            Text("name", "Название", true),
+            Decimal("salary", "Зарплата", true)
+        }
+    };
+
+    public static EntityConfig Employees => new()
+    {
+        Title = "Сотрудники",
+        TableName = "employees",
+        OrderBy = "employee_id",
+        Fields = new()
+        {
+            Id("employee_id", "ID"),
+            Text("first_name", "Имя", true),
+            Text("last_name", "Фамилия", true),
+            Text("middle_name", "Отчество"),
+            Text("passport_data", "Паспорт", true),
+            Text("phone", "Телефон"),
+            Date("birth_date", "Дата рождения"),
+            Lookup("position_id", "Должность", true,
+                "SELECT position_id AS id, name FROM positions ORDER BY name", "id", "name")
+        }
+    };
+
+    public static EntityConfig Cities => new()
+    {
+        Title = "Города",
+        TableName = "cities",
+        OrderBy = "city_id",
+        Fields = new()
+        {
+            Id("city_id", "ID"),
+            Text("city_name", "Название города", true)
+        }
+    };
+
+    public static EntityConfig Tours => new()
+    {
+        Title = "Туры",
+        TableName = "tours",
+        OrderBy = "tour_id",
+        Fields = new()
+        {
+            Id("tour_id", "ID"),
+            Text("name", "Название тура", true),
+            Decimal("price", "Стоимость", true)
+        }
+    };
+
+    public static EntityConfig TourCities => new()
+    {
+        Title = "Города в турах",
+        TableName = "tour_cities",
+        OrderBy = "tour_id, city_id",
+        Fields = new()
+        {
+            Lookup("tour_id", "Тур", true,
+                "SELECT tour_id AS id, name FROM tours ORDER BY name", "id", "name", isKey: true),
+            Lookup("city_id", "Город", true,
+                "SELECT city_id AS id, city_name AS name FROM cities ORDER BY city_name", "id", "name", isKey: true)
+        }
+    };
+
+    public static EntityConfig TourList => new()
+    {
+        Title = "Расписание туров",
+        TableName = "tour_list",
+        OrderBy = "tour_list_id",
+        Fields = new()
+        {
+            Id("tour_list_id", "ID"),
+            Lookup("tour_id", "Тур", true,
+                "SELECT tour_id AS id, name FROM tours ORDER BY name", "id", "name"),
+            Date("start_date", "Дата начала", true),
+            Date("end_date", "Дата окончания", true)
+        }
+    };
+
+    public static EntityConfig Bookings => new()
+    {
+        Title = "Бронирования",
+        TableName = "bookings",
+        OrderBy = "booking_id",
+        Fields = new()
+        {
+            Id("booking_id", "ID"),
+            Text("booking_number", "Номер бронирования", true),
+            Lookup("client_id", "Клиент", true,
+                "SELECT client_id AS id, concat_ws(' ', last_name, first_name, middle_name) AS name FROM clients ORDER BY last_name, first_name", "id", "name"),
+            Lookup("employee_id", "Сотрудник", true,
+                "SELECT employee_id AS id, concat_ws(' ', last_name, first_name, middle_name) AS name FROM employees ORDER BY last_name, first_name", "id", "name"),
+            Lookup("tour_list_id", "Тур из расписания", true,
+                "SELECT tour_list_id AS id, tour_name || ' (' || start_date || ' - ' || end_date || ')' AS name FROM v_tour_schedule ORDER BY start_date", "id", "name"),
+            Date("booking_date", "Дата оформления", true),
+            Enum("status", "Статус", true, "новая", "подтверждена", "оплачена", "отменена")
+        }
+    };
+
+    public static EntityConfig AppUsers => new()
+    {
+        Title = "Пользователи приложения",
+        TableName = "app_users",
+        OrderBy = "user_id",
+        Fields = new()
+        {
+            Id("user_id", "ID"),
+            Text("login", "Логин", true),
+            Password("password_hash", "Пароль", false),
+            Enum("role", "Роль", true, "admin", "user"),
+            Bool("is_active", "Активен", true),
+            Lookup("employee_id", "Связанная запись", false,
+                "SELECT employee_id AS id, concat_ws(' ', last_name, first_name, middle_name) AS name FROM employees ORDER BY last_name, first_name", "id", "name")
+        }
+    };
+
+    public static EntityConfig ClientDetails => ReadOnlyView("Мои данные клиента", "clients", "client_id", true, "client_id");
+    public static EntityConfig BookingDetails => ReadOnlyView("Бронирования подробно", "v_booking_details", "booking_id", false, null);
+    public static EntityConfig TourScheduleView => ReadOnlyView("Расписание туров", "v_tour_schedule", "tour_list_id", false, null);
+    public static EntityConfig OverdueView => ReadOnlyView("Просроченные бронирования", "v_overdue_client_bookings", "booking_id", false, null);
+    public static EntityConfig AuditLog => ReadOnlyView("Журнал операций", "audit_log", "changed_at DESC", false, null);
+
+    public static EntityConfig UserBookings => ReadOnlyView("Мои бронирования", "v_booking_details", "booking_id", true, "client_id");
+    public static EntityConfig UserOverdue => ReadOnlyView("Мои просроченные бронирования", "v_overdue_client_bookings", "booking_id", true, "client_id");
+
+    private static EntityConfig ReadOnlyView(string title, string view, string orderBy, bool restrict, string? restrictColumn)
+    {
+        return new EntityConfig
+        {
+            Title = title,
+            TableName = view,
+            ViewName = view,
+            OrderBy = orderBy,
+            ReadOnly = true,
+            RestrictByCurrentEmployee = restrict,
+            RestrictColumn = restrictColumn,
+            Fields = new()
+        };
+    }
+
+    private static FieldConfig Id(string name, string label) => new()
+    {
+        Name = name,
+        Label = label,
+        Kind = FieldKind.Integer,
+        IsKey = true,
+        AutoIdentity = true,
+        ReadOnlyOnEdit = true
+    };
+
+    private static FieldConfig Text(string name, string label, bool required = false) => new()
+    {
+        Name = name,
+        Label = label,
+        Kind = FieldKind.Text,
+        Required = required
+    };
+
+    private static FieldConfig Password(string name, string label, bool required = false) => new()
+    {
+        Name = name,
+        Label = label,
+        Kind = FieldKind.Password,
+        Required = required,
+        ShowInGrid = false
+    };
+
+    private static FieldConfig Decimal(string name, string label, bool required = false) => new()
+    {
+        Name = name,
+        Label = label,
+        Kind = FieldKind.Decimal,
+        Required = required
+    };
+
+    private static FieldConfig Date(string name, string label, bool required = false) => new()
+    {
+        Name = name,
+        Label = label,
+        Kind = FieldKind.Date,
+        Required = required
+    };
+
+    private static FieldConfig Bool(string name, string label, bool required = false) => new()
+    {
+        Name = name,
+        Label = label,
+        Kind = FieldKind.Boolean,
+        Required = required
+    };
+
+    private static FieldConfig Enum(string name, string label, bool required, params string[] options) => new()
+    {
+        Name = name,
+        Label = label,
+        Kind = FieldKind.Enum,
+        Required = required,
+        Options = options
+    };
+
+    private static FieldConfig Lookup(
+        string name,
+        string label,
+        bool required,
+        string sql,
+        string valueColumn,
+        string displayColumn,
+        bool isKey = false) => new()
+    {
+        Name = name,
+        Label = label,
+        Kind = FieldKind.Lookup,
+        Required = required,
+        LookupSql = sql,
+        LookupValueColumn = valueColumn,
+        LookupDisplayColumn = displayColumn,
+        IsKey = isKey,
+        AutoIdentity = false,
+        ReadOnlyOnEdit = isKey
+    };
+}

+ 23 - 0
TravelAgencyWinForms/TravelAgencyWinForms.csproj

@@ -0,0 +1,23 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <OutputType>WinExe</OutputType>
+    <TargetFramework>net8.0-windows</TargetFramework>
+    <Nullable>enable</Nullable>
+    <UseWindowsForms>true</UseWindowsForms>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <EnableWindowsTargeting>true</EnableWindowsTargeting>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Npgsql" Version="8.0.6" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <None Update="connection.txt">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </None>
+    <Content Include="Assets\*.png">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </Content>
+  </ItemGroup>
+</Project>

+ 245 - 0
TravelAgencyWinForms/Utils/AppTheme.cs

@@ -0,0 +1,245 @@
+using System.Drawing.Drawing2D;
+
+namespace TravelAgencyWinForms.Utils;
+
+public static class AppTheme
+{
+    public static Color Background => Color.FromArgb(243, 251, 248);
+    public static Color Surface => Color.White;
+    public static Color SurfaceAlt => Color.FromArgb(234, 247, 243);
+    public static Color Primary => Color.FromArgb(129, 203, 188);
+    public static Color PrimaryDark => Color.FromArgb(54, 121, 111);
+    public static Color PrimarySoft => Color.FromArgb(214, 240, 233);
+    public static Color Border => Color.FromArgb(202, 227, 220);
+    public static Color Text => Color.FromArgb(44, 67, 62);
+    public static Color Muted => Color.FromArgb(99, 127, 121);
+    public static Color Danger => Color.FromArgb(205, 110, 110);
+
+    public static void ApplyForm(Form form, bool dialog = false)
+    {
+        form.BackColor = Background;
+        form.Font = new Font("Segoe UI", 10F, FontStyle.Regular, GraphicsUnit.Point);
+        form.ForeColor = Text;
+        if (dialog)
+            form.FormBorderStyle = FormBorderStyle.FixedDialog;
+    }
+
+    public static void StylePrimaryButton(Button button)
+    {
+        StyleButton(button, Primary, PrimaryDark, Color.White);
+    }
+
+    public static void StyleSecondaryButton(Button button)
+    {
+        StyleButton(button, Surface, Border, Text);
+    }
+
+    public static void StyleSelectedButton(Button button)
+    {
+        StyleButton(button, Primary, PrimaryDark, Color.White);
+    }
+
+    public static void StyleDangerButton(Button button)
+    {
+        StyleButton(button, Color.FromArgb(251, 235, 235), Danger, Danger);
+    }
+
+    private static void StyleButton(Button button, Color backColor, Color borderColor, Color foreColor)
+    {
+        button.FlatStyle = FlatStyle.Flat;
+        button.FlatAppearance.BorderSize = 1;
+        button.FlatAppearance.BorderColor = borderColor;
+        button.BackColor = backColor;
+        button.ForeColor = foreColor;
+        button.Cursor = Cursors.Hand;
+        button.Margin = new Padding(4);
+        button.Padding = new Padding(8, 4, 8, 4);
+        button.AutoSize = false;
+    }
+
+    public static void StyleMenuButton(Button button)
+    {
+        button.FlatStyle = FlatStyle.Flat;
+        button.FlatAppearance.BorderSize = 0;
+        button.BackColor = Color.Transparent;
+        button.ForeColor = Color.White;
+        button.Cursor = Cursors.Hand;
+        button.TextAlign = ContentAlignment.MiddleLeft;
+        button.Font = new Font("Segoe UI Semibold", 10F, FontStyle.Regular, GraphicsUnit.Point);
+        button.Padding = new Padding(16, 0, 0, 0);
+    }
+
+    public static void StyleTextBox(TextBox textBox)
+    {
+        textBox.BorderStyle = BorderStyle.FixedSingle;
+        textBox.BackColor = Color.White;
+        textBox.ForeColor = Text;
+        textBox.Margin = new Padding(4);
+    }
+
+    public static void StyleComboBox(ComboBox comboBox)
+    {
+        comboBox.FlatStyle = FlatStyle.Flat;
+        comboBox.BackColor = Color.White;
+        comboBox.ForeColor = Text;
+        comboBox.Margin = new Padding(4);
+    }
+
+    public static void StyleGrid(DataGridView grid)
+    {
+        grid.BackgroundColor = Surface;
+        grid.BorderStyle = BorderStyle.None;
+        grid.GridColor = Border;
+        grid.EnableHeadersVisualStyles = false;
+        grid.ColumnHeadersBorderStyle = DataGridViewHeaderBorderStyle.Single;
+        grid.ColumnHeadersDefaultCellStyle.BackColor = PrimarySoft;
+        grid.ColumnHeadersDefaultCellStyle.ForeColor = Text;
+        grid.ColumnHeadersDefaultCellStyle.SelectionBackColor = Primary;
+        grid.ColumnHeadersDefaultCellStyle.SelectionForeColor = Color.White;
+        grid.ColumnHeadersDefaultCellStyle.Font = new Font("Segoe UI Semibold", 10F);
+        grid.DefaultCellStyle.SelectionBackColor = Color.FromArgb(224, 244, 239);
+        grid.DefaultCellStyle.SelectionForeColor = Text;
+        grid.DefaultCellStyle.BackColor = Color.White;
+        grid.DefaultCellStyle.ForeColor = Text;
+        grid.DefaultCellStyle.WrapMode = DataGridViewTriState.False;
+        grid.AlternatingRowsDefaultCellStyle.BackColor = Color.FromArgb(249, 253, 252);
+        grid.RowHeadersVisible = false;
+        grid.AutoSizeRowsMode = DataGridViewAutoSizeRowsMode.None;
+        grid.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
+        grid.MultiSelect = false;
+        grid.AllowUserToResizeRows = false;
+        grid.AllowUserToAddRows = false;
+        grid.AllowUserToDeleteRows = false;
+        grid.ReadOnly = true;
+    }
+
+    public static Panel CreateCard(string title, string value, string? subtitle = null)
+    {
+        var panel = new Panel
+        {
+            Width = 220,
+            Height = 108,
+            BackColor = Surface,
+            BorderStyle = BorderStyle.FixedSingle,
+            Margin = new Padding(6),
+            Padding = new Padding(14)
+        };
+
+        var titleLabel = new Label
+        {
+            Text = title,
+            Dock = DockStyle.Top,
+            Height = 22,
+            ForeColor = Muted,
+            Font = new Font("Segoe UI", 9.5F, FontStyle.Regular)
+        };
+
+        var valueLabel = new Label
+        {
+            Text = value,
+            Dock = DockStyle.Top,
+            Height = 38,
+            ForeColor = Text,
+            Font = new Font("Segoe UI Semibold", 20F, FontStyle.Regular)
+        };
+
+        panel.Controls.Add(valueLabel);
+        panel.Controls.Add(titleLabel);
+
+        if (!string.IsNullOrWhiteSpace(subtitle))
+        {
+            var subtitleLabel = new Label
+            {
+                Text = subtitle,
+                Dock = DockStyle.Bottom,
+                Height = 18,
+                ForeColor = Muted,
+                Font = new Font("Segoe UI", 8.5F, FontStyle.Regular)
+            };
+            panel.Controls.Add(subtitleLabel);
+        }
+
+        return panel;
+    }
+
+    public static GroupBox CreateSection(string title)
+    {
+        return new GroupBox
+        {
+            Text = title,
+            Dock = DockStyle.Fill,
+            Padding = new Padding(12),
+            ForeColor = Text,
+            Font = new Font("Segoe UI Semibold", 10F, FontStyle.Regular)
+        };
+    }
+
+    public static PictureBox CreatePictureBox(Image image, Size size)
+    {
+        return new PictureBox
+        {
+            Image = image,
+            SizeMode = PictureBoxSizeMode.Zoom,
+            Size = size,
+            BackColor = Color.Transparent
+        };
+    }
+
+    public static Panel CreateHeaderPanel(string title, string subtitle)
+    {
+        var panel = new Panel
+        {
+            Height = 86,
+            Dock = DockStyle.Top,
+            BackColor = Surface,
+            Padding = new Padding(20, 16, 20, 16),
+            Margin = new Padding(0, 0, 0, 10),
+            BorderStyle = BorderStyle.FixedSingle
+        };
+
+        var titleLabel = new Label
+        {
+            Text = title,
+            Dock = DockStyle.Top,
+            Height = 30,
+            Font = new Font("Segoe UI Semibold", 18F, FontStyle.Regular),
+            ForeColor = Text
+        };
+
+        var subtitleLabel = new Label
+        {
+            Text = subtitle,
+            Dock = DockStyle.Fill,
+            Font = new Font("Segoe UI", 9.5F, FontStyle.Regular),
+            ForeColor = Muted
+        };
+
+        panel.Controls.Add(subtitleLabel);
+        panel.Controls.Add(titleLabel);
+        return panel;
+    }
+
+    public static Image? LoadImage(string fileName)
+    {
+        var candidates = new[]
+        {
+            Path.Combine(AppContext.BaseDirectory, "Assets", fileName),
+            Path.Combine(Application.StartupPath, "Assets", fileName),
+            Path.Combine(AppContext.BaseDirectory, fileName),
+            Path.Combine(Directory.GetCurrentDirectory(), "Assets", fileName),
+            Path.Combine(Directory.GetCurrentDirectory(), "TravelAgencyWinForms", "Assets", fileName)
+        };
+
+        foreach (var path in candidates)
+        {
+            if (!File.Exists(path))
+                continue;
+
+            using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
+            using var image = Image.FromStream(stream);
+            return new Bitmap(image);
+        }
+
+        return null;
+    }
+}

+ 58 - 0
TravelAgencyWinForms/Utils/CalculatedFields.cs

@@ -0,0 +1,58 @@
+using System.Data;
+
+namespace TravelAgencyWinForms.Utils;
+
+public static class CalculatedFields
+{
+    private static readonly Dictionary<string, string> Labels = new(StringComparer.OrdinalIgnoreCase)
+    {
+        ["age_years"] = "Возраст сотрудника",
+        ["duration_days"] = "Длительность тура",
+        ["days_overdue"] = "Дней просрочки",
+        ["is_overdue"] = "Просрочено"
+    };
+
+    public static bool IsCalculated(string columnName)
+    {
+        return Labels.ContainsKey(columnName);
+    }
+
+    public static List<string> GetColumns(DataTable table)
+    {
+        return table.Columns
+            .Cast<DataColumn>()
+            .Where(column => IsCalculated(column.ColumnName))
+            .Select(column => column.ColumnName)
+            .ToList();
+    }
+
+    public static string GetLabel(string columnName)
+    {
+        return Labels.TryGetValue(columnName, out var label) ? label : columnName;
+    }
+
+    public static string FormatValue(string columnName, object? value)
+    {
+        if (value == null || value == DBNull.Value)
+            return "—";
+
+        if (columnName.Equals("is_overdue", StringComparison.OrdinalIgnoreCase))
+        {
+            if (value is bool boolValue)
+                return boolValue ? "Да" : "Нет";
+
+            return value.ToString()?.Equals("true", StringComparison.OrdinalIgnoreCase) == true ? "Да" : "Нет";
+        }
+
+        if (columnName.Equals("age_years", StringComparison.OrdinalIgnoreCase))
+            return value + " лет";
+
+        if (columnName.Equals("duration_days", StringComparison.OrdinalIgnoreCase))
+            return value + " дн.";
+
+        if (columnName.Equals("days_overdue", StringComparison.OrdinalIgnoreCase))
+            return value + " дн.";
+
+        return value.ToString() ?? "—";
+    }
+}

+ 138 - 0
TravelAgencyWinForms/Utils/ErrorHelper.cs

@@ -0,0 +1,138 @@
+using Npgsql;
+
+namespace TravelAgencyWinForms.Utils;
+
+public static class ErrorHelper
+{
+    private static readonly Dictionary<string, string> TableNames = new(StringComparer.OrdinalIgnoreCase)
+    {
+        ["clients"] = "клиентах",
+        ["employees"] = "сотрудниках",
+        ["positions"] = "должностях",
+        ["cities"] = "городах",
+        ["tours"] = "турах",
+        ["tour_cities"] = "маршрутах туров",
+        ["tour_list"] = "расписании туров",
+        ["bookings"] = "бронированиях",
+        ["app_users"] = "пользователях",
+        ["audit_log"] = "журнале операций"
+    };
+
+    public static string ToUserMessage(Exception exception)
+    {
+        if (exception is PostgresException postgres)
+            return TranslatePostgres(postgres);
+
+        var message = exception.Message;
+
+        if (message.Contains("23503") || message.Contains("referenced from table", StringComparison.OrdinalIgnoreCase))
+            return "Запись нельзя удалить, потому что она используется в связанных данных.";
+
+        if (message.Contains("duplicate key", StringComparison.OrdinalIgnoreCase))
+            return "Запись с такими уникальными данными уже существует.";
+
+        return message;
+    }
+
+    private static string TranslatePostgres(PostgresException error)
+    {
+        var constraint = error.ConstraintName ?? string.Empty;
+        var detail = error.Detail ?? string.Empty;
+
+        if (error.SqlState == "23503")
+            return ForeignKeyMessage(error, detail);
+
+        if (error.SqlState == "23505")
+            return DuplicateMessage(constraint);
+
+        if (error.SqlState == "23502")
+            return "Заполните обязательные поля.";
+
+        if (error.SqlState == "23514")
+            return CheckMessage(constraint);
+
+        return string.IsNullOrWhiteSpace(error.MessageText)
+            ? "Не удалось выполнить операцию. Проверьте данные и попробуйте ещё раз."
+            : error.MessageText;
+    }
+
+    private static string ForeignKeyMessage(PostgresException error, string detail)
+    {
+        var relatedTable = ExtractRelatedTable(detail);
+        if (!string.IsNullOrWhiteSpace(relatedTable) && TableNames.TryGetValue(relatedTable, out var tableLabel))
+            return $"Запись нельзя удалить, потому что она используется в {tableLabel}.";
+
+        if (!string.IsNullOrWhiteSpace(error.TableName) && TableNames.TryGetValue(error.TableName, out var currentTableLabel))
+            return $"Операцию нельзя выполнить: запись связана с данными в разделе «{currentTableLabel}».";
+
+        return "Запись нельзя удалить, потому что она используется в связанных данных.";
+    }
+
+    private static string ExtractRelatedTable(string detail)
+    {
+        var marker = "referenced from table";
+        var index = detail.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
+        if (index < 0)
+            return string.Empty;
+
+        var tail = detail[(index + marker.Length)..].Trim();
+
+        if (tail.StartsWith('"'))
+        {
+            var end = tail.IndexOf('"', 1);
+            if (end > 1)
+                return tail[1..end];
+        }
+
+        return tail.Split(' ', '.', ',', ';').FirstOrDefault() ?? string.Empty;
+    }
+
+    private static string DuplicateMessage(string constraint)
+    {
+        if (constraint.Contains("passport", StringComparison.OrdinalIgnoreCase))
+            return "Запись с такими паспортными данными уже существует.";
+
+        if (constraint.Contains("booking_number", StringComparison.OrdinalIgnoreCase))
+            return "Бронирование с таким номером уже существует.";
+
+        if (constraint.Contains("login", StringComparison.OrdinalIgnoreCase))
+            return "Пользователь с таким логином уже существует.";
+
+        if (constraint.Contains("city", StringComparison.OrdinalIgnoreCase))
+            return "Такой город уже есть в справочнике.";
+
+        if (constraint.Contains("position", StringComparison.OrdinalIgnoreCase))
+            return "Такая должность уже есть в справочнике.";
+
+        return "Запись с такими уникальными данными уже существует.";
+    }
+
+    private static string CheckMessage(string constraint)
+    {
+        if (constraint.Contains("phone", StringComparison.OrdinalIgnoreCase))
+            return "Телефон должен содержать 10–12 цифр и может начинаться с +.";
+
+        if (constraint.Contains("email", StringComparison.OrdinalIgnoreCase))
+            return "Введите корректный адрес электронной почты.";
+
+        if (constraint.Contains("passport", StringComparison.OrdinalIgnoreCase))
+            return "Паспортные данные должны быть в формате: 0000 000000.";
+
+        if (constraint.Contains("salary", StringComparison.OrdinalIgnoreCase))
+            return "Зарплата не может быть отрицательной.";
+
+        if (constraint.Contains("price", StringComparison.OrdinalIgnoreCase))
+            return "Стоимость тура не может быть отрицательной.";
+
+        if (constraint.Contains("birth", StringComparison.OrdinalIgnoreCase))
+            return "Дата рождения указана некорректно.";
+
+        if (constraint.Contains("tour_list_dates", StringComparison.OrdinalIgnoreCase))
+            return "Дата окончания тура не может быть раньше даты начала.";
+
+        if (constraint.Contains("status", StringComparison.OrdinalIgnoreCase))
+            return "Выбран недопустимый статус бронирования.";
+
+        return "Проверьте корректность заполненных данных.";
+    }
+}

+ 126 - 0
TravelAgencyWinForms/Utils/PresentationHelper.cs

@@ -0,0 +1,126 @@
+using System.Data;
+
+namespace TravelAgencyWinForms.Utils;
+
+public static class PresentationHelper
+{
+    private static readonly Dictionary<string, string> Labels = new(StringComparer.OrdinalIgnoreCase)
+    {
+        ["audit_id"] = "ID",
+        ["client_id"] = "ID клиента",
+        ["employee_id"] = "ID сотрудника",
+        ["position_id"] = "ID должности",
+        ["tour_id"] = "ID тура",
+        ["city_id"] = "ID города",
+        ["tour_list_id"] = "ID рейса",
+        ["booking_id"] = "ID бронирования",
+        ["user_id"] = "ID пользователя",
+
+        ["first_name"] = "Имя",
+        ["last_name"] = "Фамилия",
+        ["middle_name"] = "Отчество",
+        ["client_name"] = "Клиент",
+        ["client_phone"] = "Телефон клиента",
+        ["client_email"] = "Email клиента",
+        ["employee_name"] = "Сотрудник",
+        ["position_name"] = "Должность",
+
+        ["passport_data"] = "Паспорт",
+        ["phone"] = "Телефон",
+        ["email"] = "Email",
+        ["registration_date"] = "Дата регистрации",
+        ["birth_date"] = "Дата рождения",
+        ["salary"] = "Зарплата",
+        ["price"] = "Стоимость",
+
+        ["name"] = "Название",
+        ["tour_name"] = "Тур",
+        ["city_name"] = "Город",
+        ["cities"] = "Города",
+        ["type"] = "Тур",
+        ["start_date"] = "Дата начала",
+        ["end_date"] = "Дата окончания",
+
+        ["booking_number"] = "Номер",
+        ["booking_date"] = "Дата оформления",
+        ["status"] = "Статус",
+
+        ["login"] = "Логин",
+        ["role"] = "Роль",
+        ["is_active"] = "Активен",
+
+        ["table_name"] = "Таблица",
+        ["operation"] = "Операция",
+        ["record_id"] = "Код записи",
+        ["changed_by"] = "Пользователь",
+        ["changed_by_db_user"] = "Пользователь БД",
+        ["changed_by_app_user"] = "Пользователь приложения",
+        ["changed_at"] = "Дата изменения",
+        ["old_data"] = "Было",
+        ["new_data"] = "Стало"
+    };
+
+    private static readonly HashSet<string> HiddenViewColumns = new(StringComparer.OrdinalIgnoreCase)
+    {
+        "employee_id",
+        "client_id",
+        "tour_list_id",
+        "tour_id",
+        "city_id",
+        "position_id",
+        "user_id",
+        "password_hash"
+    };
+
+    private static readonly HashSet<string> HiddenReportColumns = new(StringComparer.OrdinalIgnoreCase)
+    {
+        "audit_id",
+        "booking_id",
+        "employee_id",
+        "client_id",
+        "tour_list_id",
+        "tour_id",
+        "city_id",
+        "position_id",
+        "user_id",
+        "password_hash"
+    };
+
+    public static string GetHeader(string columnName)
+    {
+        if (Labels.TryGetValue(columnName, out var label))
+            return label;
+
+        var words = columnName.Split('_', StringSplitOptions.RemoveEmptyEntries);
+        return string.Join(' ', words.Select(w => char.ToUpper(w[0]) + w[1..]));
+    }
+
+    public static void ApplyHeaders(DataGridView grid)
+    {
+        foreach (DataGridViewColumn column in grid.Columns)
+            column.HeaderText = GetHeader(column.Name);
+    }
+
+    public static void HideViewColumns(DataGridView grid)
+    {
+        foreach (DataGridViewColumn column in grid.Columns)
+            if (HiddenViewColumns.Contains(column.Name))
+                column.Visible = false;
+    }
+
+    public static void HideReportColumns(DataGridView grid)
+    {
+        foreach (DataGridViewColumn column in grid.Columns)
+            if (HiddenReportColumns.Contains(column.Name))
+                column.Visible = false;
+    }
+
+    public static string ShortDate(object? value)
+    {
+        if (value == null || value == DBNull.Value)
+            return "—";
+        if (value is DateTime dateTime)
+            return dateTime.ToString("dd.MM.yyyy");
+        return Convert.ToString(value) ?? "—";
+    }
+}

+ 1 - 0
TravelAgencyWinForms/connection.example.txt

@@ -0,0 +1 @@
+Host=edu-pg.itiscaf.ru;Port=5432;Database=dbwork;Username=shchetnikov_id;Password=YOUR_PASSWORD;Search Path=shchetnikov_id;Include Error Detail=true

+ 1 - 0
TravelAgencyWinForms/connection.txt

@@ -0,0 +1 @@
+Host=edu-pg.itiscaf.ru;Port=5432;Database=dbwork;Username=shchetnikov_id;Password=#10j$bn0&22;Search Path=shchetnikov_id;Include Error Detail=true

+ 683 - 0
installer/installer.vdproj

@@ -0,0 +1,683 @@
+"DeployProject"
+{
+"VSVersion" = "3:800"
+"ProjectType" = "8:{978C614F-708E-4E1A-B201-565925725DBA}"
+"IsWebType" = "8:FALSE"
+"ProjectName" = "8:installer"
+"LanguageId" = "3:1049"
+"CodePage" = "3:1251"
+"UILanguageId" = "3:1049"
+"SccProjectName" = "8:"
+"SccLocalPath" = "8:"
+"SccAuxPath" = "8:"
+"SccProvider" = "8:"
+    "Hierarchy"
+    {
+        "Entry"
+        {
+        "MsmKey" = "8:_2E397065430647C2983C2CF43C98768B"
+        "OwnerKey" = "8:_UNDEFINED"
+        "MsmSig" = "8:_UNDEFINED"
+        }
+    }
+    "Configurations"
+    {
+        "Debug"
+        {
+        "DisplayName" = "8:Debug"
+        "IsDebugOnly" = "11:TRUE"
+        "IsReleaseOnly" = "11:FALSE"
+        "OutputFilename" = "8:Debug\\installer.msi"
+        "PackageFilesAs" = "3:2"
+        "PackageFileSize" = "3:-2147483648"
+        "CabType" = "3:1"
+        "Compression" = "3:2"
+        "SignOutput" = "11:FALSE"
+        "CertificateFile" = "8:"
+        "PrivateKeyFile" = "8:"
+        "TimeStampServer" = "8:"
+        "InstallerBootstrapper" = "3:2"
+        }
+        "Release"
+        {
+        "DisplayName" = "8:Release"
+        "IsDebugOnly" = "11:FALSE"
+        "IsReleaseOnly" = "11:TRUE"
+        "OutputFilename" = "8:Release\\installer.msi"
+        "PackageFilesAs" = "3:2"
+        "PackageFileSize" = "3:-2147483648"
+        "CabType" = "3:1"
+        "Compression" = "3:2"
+        "SignOutput" = "11:FALSE"
+        "CertificateFile" = "8:"
+        "PrivateKeyFile" = "8:"
+        "TimeStampServer" = "8:"
+        "InstallerBootstrapper" = "3:2"
+        }
+    }
+    "Deployable"
+    {
+        "CustomAction"
+        {
+        }
+        "DefaultFeature"
+        {
+        "Name" = "8:DefaultFeature"
+        "Title" = "8:"
+        "Description" = "8:"
+        }
+        "ExternalPersistence"
+        {
+            "LaunchCondition"
+            {
+            }
+        }
+        "File"
+        {
+        }
+        "FileType"
+        {
+        }
+        "Folder"
+        {
+            "{3C67513D-01DD-4637-8A68-80971EB9504F}:_8B92AAE576A54128AB82E72F1EB7F8FD"
+            {
+            "DefaultLocation" = "8:[ProgramFilesFolder][Manufacturer]\\[ProductName]"
+            "Name" = "8:#1925"
+            "AlwaysCreate" = "11:FALSE"
+            "Condition" = "8:"
+            "Transitive" = "11:FALSE"
+            "Property" = "8:TARGETDIR"
+                "Folders"
+                {
+                }
+            }
+            "{1525181F-901A-416C-8A58-119130FE478E}:_C048C643ACC14C7E8FA1E1C30C61177D"
+            {
+            "Name" = "8:#1916"
+            "AlwaysCreate" = "11:FALSE"
+            "Condition" = "8:"
+            "Transitive" = "11:FALSE"
+            "Property" = "8:DesktopFolder"
+                "Folders"
+                {
+                }
+            }
+            "{1525181F-901A-416C-8A58-119130FE478E}:_C5AAED9548554AB4A3FB371B0DD6059E"
+            {
+            "Name" = "8:#1919"
+            "AlwaysCreate" = "11:FALSE"
+            "Condition" = "8:"
+            "Transitive" = "11:FALSE"
+            "Property" = "8:ProgramMenuFolder"
+                "Folders"
+                {
+                }
+            }
+        }
+        "LaunchCondition"
+        {
+        }
+        "Locator"
+        {
+        }
+        "MsiBootstrapper"
+        {
+        "LangId" = "3:1049"
+        "RequiresElevation" = "11:FALSE"
+        }
+        "Product"
+        {
+        "Name" = "8:Microsoft Visual Studio"
+        "ProductName" = "8:installer"
+        "ProductCode" = "8:{7F452308-0FF6-491F-A944-79A3EF20B2B1}"
+        "PackageCode" = "8:{B9FE6DF3-5D3F-4DB5-AD75-E03CCB4CC7F4}"
+        "UpgradeCode" = "8:{3294EDDA-2A0B-436A-8EF0-489969CC4F1D}"
+        "AspNetVersion" = "8:2.0.50727.0"
+        "RestartWWWService" = "11:FALSE"
+        "RemovePreviousVersions" = "11:TRUE"
+        "DetectNewerInstalledVersion" = "11:TRUE"
+        "InstallAllUsers" = "11:FALSE"
+        "ProductVersion" = "8:1.0.0"
+        "Manufacturer" = "8:ptk4996"
+        "ARPHELPTELEPHONE" = "8:"
+        "ARPHELPLINK" = "8:"
+        "Title" = "8:installer"
+        "Subject" = "8:"
+        "ARPCONTACT" = "8:ptk4996"
+        "Keywords" = "8:"
+        "ARPCOMMENTS" = "8:"
+        "ARPURLINFOABOUT" = "8:"
+        "ARPPRODUCTICON" = "8:"
+        "ARPIconIndex" = "3:0"
+        "SearchPath" = "8:"
+        "UseSystemSearchPath" = "11:TRUE"
+        "TargetPlatform" = "3:0"
+        "PreBuildEvent" = "8:"
+        "PostBuildEvent" = "8:"
+        "RunPostBuildEvent" = "3:0"
+        }
+        "Registry"
+        {
+            "HKLM"
+            {
+                "Keys"
+                {
+                    "{60EA8692-D2D5-43EB-80DC-7906BF13D6EF}:_5BA35355174B4DADBC2E8C66D6B50CC1"
+                    {
+                    "Name" = "8:Software"
+                    "Condition" = "8:"
+                    "AlwaysCreate" = "11:FALSE"
+                    "DeleteAtUninstall" = "11:FALSE"
+                    "Transitive" = "11:FALSE"
+                        "Keys"
+                        {
+                            "{60EA8692-D2D5-43EB-80DC-7906BF13D6EF}:_69B45696E5EE46049D83FCFCB686AD3E"
+                            {
+                            "Name" = "8:[Manufacturer]"
+                            "Condition" = "8:"
+                            "AlwaysCreate" = "11:FALSE"
+                            "DeleteAtUninstall" = "11:FALSE"
+                            "Transitive" = "11:FALSE"
+                                "Keys"
+                                {
+                                }
+                                "Values"
+                                {
+                                }
+                            }
+                        }
+                        "Values"
+                        {
+                        }
+                    }
+                }
+            }
+            "HKCU"
+            {
+                "Keys"
+                {
+                    "{60EA8692-D2D5-43EB-80DC-7906BF13D6EF}:_E0FE1404CE6C440DBEA529BD1B47E343"
+                    {
+                    "Name" = "8:Software"
+                    "Condition" = "8:"
+                    "AlwaysCreate" = "11:FALSE"
+                    "DeleteAtUninstall" = "11:FALSE"
+                    "Transitive" = "11:FALSE"
+                        "Keys"
+                        {
+                            "{60EA8692-D2D5-43EB-80DC-7906BF13D6EF}:_C3F9B36EC91C4E11B391D06046BDA03F"
+                            {
+                            "Name" = "8:[Manufacturer]"
+                            "Condition" = "8:"
+                            "AlwaysCreate" = "11:FALSE"
+                            "DeleteAtUninstall" = "11:FALSE"
+                            "Transitive" = "11:FALSE"
+                                "Keys"
+                                {
+                                }
+                                "Values"
+                                {
+                                }
+                            }
+                        }
+                        "Values"
+                        {
+                        }
+                    }
+                }
+            }
+            "HKCR"
+            {
+                "Keys"
+                {
+                }
+            }
+            "HKU"
+            {
+                "Keys"
+                {
+                }
+            }
+            "HKPU"
+            {
+                "Keys"
+                {
+                }
+            }
+        }
+        "Sequences"
+        {
+        }
+        "Shortcut"
+        {
+        }
+        "UserInterface"
+        {
+            "{2479F3F5-0309-486D-8047-8187E2CE5BA0}:_293FBF7206114EACAFD3A6B59EC4A297"
+            {
+            "UseDynamicProperties" = "11:FALSE"
+            "IsDependency" = "11:FALSE"
+            "SourcePath" = "8:<VsdDialogDir>\\VsdBasicDialogs.wim"
+            }
+            "{DF760B10-853B-4699-99F2-AFF7185B4A62}:_385DC33B797B49A3991A947CF34578C0"
+            {
+            "Name" = "8:#1902"
+            "Sequence" = "3:2"
+            "Attributes" = "3:3"
+                "Dialogs"
+                {
+                    "{688940B3-5CA9-4162-8DEE-2993FA9D8CBC}:_74157013ED454515B9F64E3BB82E7A11"
+                    {
+                    "Sequence" = "3:100"
+                    "DisplayName" = "8:Готово"
+                    "UseDynamicProperties" = "11:TRUE"
+                    "IsDependency" = "11:FALSE"
+                    "SourcePath" = "8:<VsdDialogDir>\\VsdAdminFinishedDlg.wid"
+                        "Properties"
+                        {
+                            "BannerBitmap"
+                            {
+                            "Name" = "8:BannerBitmap"
+                            "DisplayName" = "8:#1001"
+                            "Description" = "8:#1101"
+                            "Type" = "3:8"
+                            "ContextData" = "8:Bitmap"
+                            "Attributes" = "3:4"
+                            "Setting" = "3:1"
+                            "UsePlugInResources" = "11:TRUE"
+                            }
+                        }
+                    }
+                }
+            }
+            "{DF760B10-853B-4699-99F2-AFF7185B4A62}:_6B0A6FF6845A4DFE870290C0FB5A8B98"
+            {
+            "Name" = "8:#1900"
+            "Sequence" = "3:2"
+            "Attributes" = "3:1"
+                "Dialogs"
+                {
+                    "{688940B3-5CA9-4162-8DEE-2993FA9D8CBC}:_3F7A37DDA5C44FF1948652C454C6C5AE"
+                    {
+                    "Sequence" = "3:100"
+                    "DisplayName" = "8:Добро пожаловать!"
+                    "UseDynamicProperties" = "11:TRUE"
+                    "IsDependency" = "11:FALSE"
+                    "SourcePath" = "8:<VsdDialogDir>\\VsdAdminWelcomeDlg.wid"
+                        "Properties"
+                        {
+                            "BannerBitmap"
+                            {
+                            "Name" = "8:BannerBitmap"
+                            "DisplayName" = "8:#1001"
+                            "Description" = "8:#1101"
+                            "Type" = "3:8"
+                            "ContextData" = "8:Bitmap"
+                            "Attributes" = "3:4"
+                            "Setting" = "3:1"
+                            "UsePlugInResources" = "11:TRUE"
+                            }
+                            "CopyrightWarning"
+                            {
+                            "Name" = "8:CopyrightWarning"
+                            "DisplayName" = "8:#1002"
+                            "Description" = "8:#1102"
+                            "Type" = "3:3"
+                            "ContextData" = "8:"
+                            "Attributes" = "3:0"
+                            "Setting" = "3:1"
+                            "Value" = "8:#1202"
+                            "DefaultValue" = "8:#1202"
+                            "UsePlugInResources" = "11:TRUE"
+                            }
+                            "Welcome"
+                            {
+                            "Name" = "8:Welcome"
+                            "DisplayName" = "8:#1003"
+                            "Description" = "8:#1103"
+                            "Type" = "3:3"
+                            "ContextData" = "8:"
+                            "Attributes" = "3:0"
+                            "Setting" = "3:1"
+                            "Value" = "8:#1203"
+                            "DefaultValue" = "8:#1203"
+                            "UsePlugInResources" = "11:TRUE"
+                            }
+                        }
+                    }
+                    "{688940B3-5CA9-4162-8DEE-2993FA9D8CBC}:_680674BF95044FDDA6089153ACC2A000"
+                    {
+                    "Sequence" = "3:300"
+                    "DisplayName" = "8:Подтверждение установки"
+                    "UseDynamicProperties" = "11:TRUE"
+                    "IsDependency" = "11:FALSE"
+                    "SourcePath" = "8:<VsdDialogDir>\\VsdAdminConfirmDlg.wid"
+                        "Properties"
+                        {
+                            "BannerBitmap"
+                            {
+                            "Name" = "8:BannerBitmap"
+                            "DisplayName" = "8:#1001"
+                            "Description" = "8:#1101"
+                            "Type" = "3:8"
+                            "ContextData" = "8:Bitmap"
+                            "Attributes" = "3:4"
+                            "Setting" = "3:1"
+                            "UsePlugInResources" = "11:TRUE"
+                            }
+                        }
+                    }
+                    "{688940B3-5CA9-4162-8DEE-2993FA9D8CBC}:_7974193AFEE04B149B72AAAE02756676"
+                    {
+                    "Sequence" = "3:200"
+                    "DisplayName" = "8:Папка установки"
+                    "UseDynamicProperties" = "11:TRUE"
+                    "IsDependency" = "11:FALSE"
+                    "SourcePath" = "8:<VsdDialogDir>\\VsdAdminFolderDlg.wid"
+                        "Properties"
+                        {
+                            "BannerBitmap"
+                            {
+                            "Name" = "8:BannerBitmap"
+                            "DisplayName" = "8:#1001"
+                            "Description" = "8:#1101"
+                            "Type" = "3:8"
+                            "ContextData" = "8:Bitmap"
+                            "Attributes" = "3:4"
+                            "Setting" = "3:1"
+                            "UsePlugInResources" = "11:TRUE"
+                            }
+                        }
+                    }
+                }
+            }
+            "{DF760B10-853B-4699-99F2-AFF7185B4A62}:_8818331C6F0F4093A89C863399E92B51"
+            {
+            "Name" = "8:#1901"
+            "Sequence" = "3:2"
+            "Attributes" = "3:2"
+                "Dialogs"
+                {
+                    "{688940B3-5CA9-4162-8DEE-2993FA9D8CBC}:_D2C6A723CCDA45C68A05298E812FFA80"
+                    {
+                    "Sequence" = "3:100"
+                    "DisplayName" = "8:Ход выполнения"
+                    "UseDynamicProperties" = "11:TRUE"
+                    "IsDependency" = "11:FALSE"
+                    "SourcePath" = "8:<VsdDialogDir>\\VsdAdminProgressDlg.wid"
+                        "Properties"
+                        {
+                            "BannerBitmap"
+                            {
+                            "Name" = "8:BannerBitmap"
+                            "DisplayName" = "8:#1001"
+                            "Description" = "8:#1101"
+                            "Type" = "3:8"
+                            "ContextData" = "8:Bitmap"
+                            "Attributes" = "3:4"
+                            "Setting" = "3:1"
+                            "UsePlugInResources" = "11:TRUE"
+                            }
+                            "ShowProgress"
+                            {
+                            "Name" = "8:ShowProgress"
+                            "DisplayName" = "8:#1009"
+                            "Description" = "8:#1109"
+                            "Type" = "3:5"
+                            "ContextData" = "8:1;True=1;False=0"
+                            "Attributes" = "3:0"
+                            "Setting" = "3:0"
+                            "Value" = "3:1"
+                            "DefaultValue" = "3:1"
+                            "UsePlugInResources" = "11:TRUE"
+                            }
+                        }
+                    }
+                }
+            }
+            "{DF760B10-853B-4699-99F2-AFF7185B4A62}:_8E9A20B3E28B4F68A32AE50BA7616DAD"
+            {
+            "Name" = "8:#1901"
+            "Sequence" = "3:1"
+            "Attributes" = "3:2"
+                "Dialogs"
+                {
+                    "{688940B3-5CA9-4162-8DEE-2993FA9D8CBC}:_392EE7A48A9E4ADB97579D539500B1DD"
+                    {
+                    "Sequence" = "3:100"
+                    "DisplayName" = "8:Ход выполнения"
+                    "UseDynamicProperties" = "11:TRUE"
+                    "IsDependency" = "11:FALSE"
+                    "SourcePath" = "8:<VsdDialogDir>\\VsdProgressDlg.wid"
+                        "Properties"
+                        {
+                            "BannerBitmap"
+                            {
+                            "Name" = "8:BannerBitmap"
+                            "DisplayName" = "8:#1001"
+                            "Description" = "8:#1101"
+                            "Type" = "3:8"
+                            "ContextData" = "8:Bitmap"
+                            "Attributes" = "3:4"
+                            "Setting" = "3:1"
+                            "UsePlugInResources" = "11:TRUE"
+                            }
+                            "ShowProgress"
+                            {
+                            "Name" = "8:ShowProgress"
+                            "DisplayName" = "8:#1009"
+                            "Description" = "8:#1109"
+                            "Type" = "3:5"
+                            "ContextData" = "8:1;True=1;False=0"
+                            "Attributes" = "3:0"
+                            "Setting" = "3:0"
+                            "Value" = "3:1"
+                            "DefaultValue" = "3:1"
+                            "UsePlugInResources" = "11:TRUE"
+                            }
+                        }
+                    }
+                }
+            }
+            "{DF760B10-853B-4699-99F2-AFF7185B4A62}:_90E7372BF0CE45C3BAF1ACAAA1BD73C6"
+            {
+            "Name" = "8:#1902"
+            "Sequence" = "3:1"
+            "Attributes" = "3:3"
+                "Dialogs"
+                {
+                    "{688940B3-5CA9-4162-8DEE-2993FA9D8CBC}:_12BE544F3BE447E9829623FB172B7FB1"
+                    {
+                    "Sequence" = "3:100"
+                    "DisplayName" = "8:Готово"
+                    "UseDynamicProperties" = "11:TRUE"
+                    "IsDependency" = "11:FALSE"
+                    "SourcePath" = "8:<VsdDialogDir>\\VsdFinishedDlg.wid"
+                        "Properties"
+                        {
+                            "BannerBitmap"
+                            {
+                            "Name" = "8:BannerBitmap"
+                            "DisplayName" = "8:#1001"
+                            "Description" = "8:#1101"
+                            "Type" = "3:8"
+                            "ContextData" = "8:Bitmap"
+                            "Attributes" = "3:4"
+                            "Setting" = "3:1"
+                            "UsePlugInResources" = "11:TRUE"
+                            }
+                            "UpdateText"
+                            {
+                            "Name" = "8:UpdateText"
+                            "DisplayName" = "8:#1058"
+                            "Description" = "8:#1158"
+                            "Type" = "3:15"
+                            "ContextData" = "8:"
+                            "Attributes" = "3:0"
+                            "Setting" = "3:1"
+                            "Value" = "8:#1258"
+                            "DefaultValue" = "8:#1258"
+                            "UsePlugInResources" = "11:TRUE"
+                            }
+                        }
+                    }
+                }
+            }
+            "{DF760B10-853B-4699-99F2-AFF7185B4A62}:_C828830D05914B049B9E591ECA7BB6AF"
+            {
+            "Name" = "8:#1900"
+            "Sequence" = "3:1"
+            "Attributes" = "3:1"
+                "Dialogs"
+                {
+                    "{688940B3-5CA9-4162-8DEE-2993FA9D8CBC}:_7C147AD410F240FAA346F0A3AD45370E"
+                    {
+                    "Sequence" = "3:100"
+                    "DisplayName" = "8:Добро пожаловать!"
+                    "UseDynamicProperties" = "11:TRUE"
+                    "IsDependency" = "11:FALSE"
+                    "SourcePath" = "8:<VsdDialogDir>\\VsdWelcomeDlg.wid"
+                        "Properties"
+                        {
+                            "BannerBitmap"
+                            {
+                            "Name" = "8:BannerBitmap"
+                            "DisplayName" = "8:#1001"
+                            "Description" = "8:#1101"
+                            "Type" = "3:8"
+                            "ContextData" = "8:Bitmap"
+                            "Attributes" = "3:4"
+                            "Setting" = "3:1"
+                            "UsePlugInResources" = "11:TRUE"
+                            }
+                            "CopyrightWarning"
+                            {
+                            "Name" = "8:CopyrightWarning"
+                            "DisplayName" = "8:#1002"
+                            "Description" = "8:#1102"
+                            "Type" = "3:3"
+                            "ContextData" = "8:"
+                            "Attributes" = "3:0"
+                            "Setting" = "3:1"
+                            "Value" = "8:#1202"
+                            "DefaultValue" = "8:#1202"
+                            "UsePlugInResources" = "11:TRUE"
+                            }
+                            "Welcome"
+                            {
+                            "Name" = "8:Welcome"
+                            "DisplayName" = "8:#1003"
+                            "Description" = "8:#1103"
+                            "Type" = "3:3"
+                            "ContextData" = "8:"
+                            "Attributes" = "3:0"
+                            "Setting" = "3:1"
+                            "Value" = "8:#1203"
+                            "DefaultValue" = "8:#1203"
+                            "UsePlugInResources" = "11:TRUE"
+                            }
+                        }
+                    }
+                    "{688940B3-5CA9-4162-8DEE-2993FA9D8CBC}:_8E98C323B2E04B1BB6E4FCF8DCD47E6B"
+                    {
+                    "Sequence" = "3:300"
+                    "DisplayName" = "8:Подтверждение установки"
+                    "UseDynamicProperties" = "11:TRUE"
+                    "IsDependency" = "11:FALSE"
+                    "SourcePath" = "8:<VsdDialogDir>\\VsdConfirmDlg.wid"
+                        "Properties"
+                        {
+                            "BannerBitmap"
+                            {
+                            "Name" = "8:BannerBitmap"
+                            "DisplayName" = "8:#1001"
+                            "Description" = "8:#1101"
+                            "Type" = "3:8"
+                            "ContextData" = "8:Bitmap"
+                            "Attributes" = "3:4"
+                            "Setting" = "3:1"
+                            "UsePlugInResources" = "11:TRUE"
+                            }
+                        }
+                    }
+                    "{688940B3-5CA9-4162-8DEE-2993FA9D8CBC}:_E40833D19EFC421590392A99634BD0FB"
+                    {
+                    "Sequence" = "3:200"
+                    "DisplayName" = "8:Папка установки"
+                    "UseDynamicProperties" = "11:TRUE"
+                    "IsDependency" = "11:FALSE"
+                    "SourcePath" = "8:<VsdDialogDir>\\VsdFolderDlg.wid"
+                        "Properties"
+                        {
+                            "BannerBitmap"
+                            {
+                            "Name" = "8:BannerBitmap"
+                            "DisplayName" = "8:#1001"
+                            "Description" = "8:#1101"
+                            "Type" = "3:8"
+                            "ContextData" = "8:Bitmap"
+                            "Attributes" = "3:4"
+                            "Setting" = "3:1"
+                            "UsePlugInResources" = "11:TRUE"
+                            }
+                            "InstallAllUsersVisible"
+                            {
+                            "Name" = "8:InstallAllUsersVisible"
+                            "DisplayName" = "8:#1059"
+                            "Description" = "8:#1159"
+                            "Type" = "3:5"
+                            "ContextData" = "8:1;True=1;False=0"
+                            "Attributes" = "3:0"
+                            "Setting" = "3:0"
+                            "Value" = "3:1"
+                            "DefaultValue" = "3:1"
+                            "UsePlugInResources" = "11:TRUE"
+                            }
+                        }
+                    }
+                }
+            }
+            "{2479F3F5-0309-486D-8047-8187E2CE5BA0}:_F3EEAE0DAC004849A57CCC5E61F49B5A"
+            {
+            "UseDynamicProperties" = "11:FALSE"
+            "IsDependency" = "11:FALSE"
+            "SourcePath" = "8:<VsdDialogDir>\\VsdUserInterface.wim"
+            }
+        }
+        "MergeModule"
+        {
+        }
+        "ProjectOutput"
+        {
+            "{5259A561-127C-4D43-A0A1-72F10C7B3BF8}:_2E397065430647C2983C2CF43C98768B"
+            {
+            "SourcePath" = "8:..\\TravelAgencyWinForms\\obj\\Debug\\net8.0-windows\\apphost.exe"
+            "TargetName" = "8:"
+            "Tag" = "8:"
+            "Folder" = "8:_8B92AAE576A54128AB82E72F1EB7F8FD"
+            "Condition" = "8:"
+            "Transitive" = "11:FALSE"
+            "Vital" = "11:TRUE"
+            "ReadOnly" = "11:FALSE"
+            "Hidden" = "11:FALSE"
+            "System" = "11:FALSE"
+            "Permanent" = "11:FALSE"
+            "SharedLegacy" = "11:FALSE"
+            "PackageAs" = "3:1"
+            "Register" = "3:1"
+            "Exclude" = "11:FALSE"
+            "IsDependency" = "11:FALSE"
+            "IsolateTo" = "8:"
+            "ProjectOutputGroupRegister" = "3:1"
+            "OutputConfiguration" = "8:"
+            "OutputGroupCanonicalName" = "8:PublishItems"
+            "OutputProjectGuid" = "8:{562A19DA-602D-4DD0-A54A-7822057E5CBD}"
+            "ShowKeyOutput" = "11:TRUE"
+                "ExcludeFilters"
+                {
+                }
+            }
+        }
+    }
+}