|
|
@@ -0,0 +1,864 @@
|
|
|
+using System.Data;
|
|
|
+using System.Diagnostics;
|
|
|
+using System.Globalization;
|
|
|
+using System.Text;
|
|
|
+
|
|
|
+namespace AutorentWinForms;
|
|
|
+
|
|
|
+public sealed class MainForm : Form
|
|
|
+{
|
|
|
+ private readonly Database _db;
|
|
|
+ private readonly UserSession _session;
|
|
|
+ private readonly TabControl _tabs = new() { Dock = DockStyle.Fill };
|
|
|
+ private readonly Dictionary<string, DataGridView> _grids = new();
|
|
|
+ private readonly Dictionary<string, TextBox> _searchBoxes = new();
|
|
|
+
|
|
|
+ private bool CanWrite => _session.Role is "admin" or "manager";
|
|
|
+ private bool CanEditBrands => _session.Role == "admin";
|
|
|
+ private bool IsAdmin => _session.Role == "admin";
|
|
|
+
|
|
|
+ public MainForm(Database db, UserSession session)
|
|
|
+ {
|
|
|
+ _db = db;
|
|
|
+ _session = session;
|
|
|
+ Text = $"Автопрокат - {_session.FullName} ({RoleName(_session.Role)})";
|
|
|
+ StartPosition = FormStartPosition.CenterScreen;
|
|
|
+ WindowState = FormWindowState.Maximized;
|
|
|
+ MinimumSize = new Size(1100, 700);
|
|
|
+ Font = new Font("Segoe UI", 10F);
|
|
|
+
|
|
|
+ Controls.Add(_tabs);
|
|
|
+ CreateDataTab("clients", "Клиенты");
|
|
|
+ CreateDataTab("cars", "Автомобили");
|
|
|
+ CreateDataTab("brands", "Марки");
|
|
|
+ CreateDataTab("rentals", "Аренда");
|
|
|
+ if (_session.Role != "viewer")
|
|
|
+ {
|
|
|
+ CreateReportsTab();
|
|
|
+ CreateSecurityTab();
|
|
|
+ CreateAuditTab();
|
|
|
+ }
|
|
|
+
|
|
|
+ LoadAll();
|
|
|
+ }
|
|
|
+
|
|
|
+ private void CreateDataTab(string table, string caption)
|
|
|
+ {
|
|
|
+ var page = new TabPage(caption);
|
|
|
+ var root = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, ColumnCount = 1 };
|
|
|
+ root.RowStyles.Add(new RowStyle(SizeType.Absolute, 48));
|
|
|
+ root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
|
|
|
+
|
|
|
+ var bar = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, Padding = new Padding(8), WrapContents = false };
|
|
|
+ var search = new TextBox { Width = 320, PlaceholderText = "Поиск..." };
|
|
|
+ search.TextChanged += (_, _) => LoadTable(table);
|
|
|
+ _searchBoxes[table] = search;
|
|
|
+
|
|
|
+ var add = new Button { Text = "Добавить", Width = 105, Height = 30, Enabled = CanWrite && (table != "brands" || CanEditBrands) };
|
|
|
+ add.Click += (_, _) => EditRecord(table, null);
|
|
|
+ var edit = new Button { Text = "Редактировать", Width = 125, Height = 30, Enabled = CanWrite && (table != "brands" || CanEditBrands) };
|
|
|
+ edit.Click += (_, _) => EditSelected(table);
|
|
|
+ var delete = new Button { Text = "Удалить", Width = 95, Height = 30, Enabled = CanWrite && (table != "brands" || CanEditBrands) };
|
|
|
+ delete.Click += (_, _) => DeleteSelected(table);
|
|
|
+ var refresh = new Button { Text = "Обновить", Width = 100, Height = 30 };
|
|
|
+ refresh.Click += (_, _) => LoadTable(table);
|
|
|
+ var export = new Button { Text = "CSV", Width = 70, Height = 30 };
|
|
|
+ export.Click += (_, _) => ExportCsv(table);
|
|
|
+
|
|
|
+ bar.Controls.AddRange([search, add, edit, delete, refresh, export]);
|
|
|
+
|
|
|
+ var grid = new DataGridView
|
|
|
+ {
|
|
|
+ Dock = DockStyle.Fill,
|
|
|
+ ReadOnly = true,
|
|
|
+ AllowUserToAddRows = false,
|
|
|
+ AllowUserToDeleteRows = false,
|
|
|
+ SelectionMode = DataGridViewSelectionMode.FullRowSelect,
|
|
|
+ MultiSelect = false,
|
|
|
+ AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
|
|
|
+ BackgroundColor = Color.White
|
|
|
+ };
|
|
|
+ grid.CellDoubleClick += (_, _) =>
|
|
|
+ {
|
|
|
+ if (CanWrite && (table != "brands" || CanEditBrands))
|
|
|
+ EditSelected(table);
|
|
|
+ };
|
|
|
+ _grids[table] = grid;
|
|
|
+
|
|
|
+ root.Controls.Add(bar, 0, 0);
|
|
|
+ root.Controls.Add(grid, 0, 1);
|
|
|
+ page.Controls.Add(root);
|
|
|
+ _tabs.TabPages.Add(page);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void CreateReportsTab()
|
|
|
+ {
|
|
|
+ var page = new TabPage("Отчеты");
|
|
|
+ var root = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, ColumnCount = 1 };
|
|
|
+ root.RowStyles.Add(new RowStyle(SizeType.Absolute, 54));
|
|
|
+ root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
|
|
|
+
|
|
|
+ var bar = new FlowLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(8) };
|
|
|
+ var refresh = new Button { Text = "Сформировать", Width = 130, Height = 32 };
|
|
|
+ var html = new Button { Text = "Отчет HTML", Width = 120, Height = 32 };
|
|
|
+ var txt = new Button { Text = "Отчет TXT", Width = 110, Height = 32 };
|
|
|
+ refresh.Click += (_, _) => LoadReports();
|
|
|
+ html.Click += (_, _) => SaveReport("html");
|
|
|
+ txt.Click += (_, _) => SaveReport("txt");
|
|
|
+ bar.Controls.AddRange([refresh, html, txt]);
|
|
|
+
|
|
|
+ var report = new TextBox
|
|
|
+ {
|
|
|
+ Name = "reportBox",
|
|
|
+ Dock = DockStyle.Fill,
|
|
|
+ Multiline = true,
|
|
|
+ ScrollBars = ScrollBars.Both,
|
|
|
+ ReadOnly = true,
|
|
|
+ Font = new Font("Consolas", 10F),
|
|
|
+ WordWrap = false
|
|
|
+ };
|
|
|
+
|
|
|
+ root.Controls.Add(bar, 0, 0);
|
|
|
+ root.Controls.Add(report, 0, 1);
|
|
|
+ page.Controls.Add(root);
|
|
|
+ _tabs.TabPages.Add(page);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void CreateSecurityTab()
|
|
|
+ {
|
|
|
+ var page = new TabPage("Защита");
|
|
|
+ page.BackColor = Color.FromArgb(245, 247, 250);
|
|
|
+
|
|
|
+ var scroll = new Panel
|
|
|
+ {
|
|
|
+ Dock = DockStyle.Fill,
|
|
|
+ AutoScroll = true,
|
|
|
+ Padding = new Padding(18),
|
|
|
+ BackColor = Color.FromArgb(245, 247, 250)
|
|
|
+ };
|
|
|
+
|
|
|
+ var content = new TableLayoutPanel
|
|
|
+ {
|
|
|
+ Dock = DockStyle.Top,
|
|
|
+ AutoSize = true,
|
|
|
+ ColumnCount = 1,
|
|
|
+ BackColor = Color.Transparent
|
|
|
+ };
|
|
|
+ content.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
|
|
+
|
|
|
+ content.Controls.Add(CreateSectionTitle("Пользователи БД и обязанности"), 0, content.RowCount++);
|
|
|
+ content.Controls.Add(CreateRolesPanel(), 0, content.RowCount++);
|
|
|
+ content.Controls.Add(CreateSectionTitle("Реализованные уровни защиты"), 0, content.RowCount++);
|
|
|
+ content.Controls.Add(CreateProtectionMatrix(), 0, content.RowCount++);
|
|
|
+ content.Controls.Add(CreateSectionTitle("Защита на уровне базы данных"), 0, content.RowCount++);
|
|
|
+ content.Controls.Add(CreateBulletsCard("Ограничения целостности и контроль данных", [
|
|
|
+ "PRIMARY KEY: brand_id, car_id, client_id, rental_id исключают дублирование основных сущностей.",
|
|
|
+ "FOREIGN KEY: car.brand_id, rental.car_id, rental.client_id защищают связи между марками, автомобилями, клиентами и договорами.",
|
|
|
+ "ON UPDATE CASCADE сохраняет связи при изменении ключей, ON DELETE RESTRICT запрещает удаление используемых записей.",
|
|
|
+ "UNIQUE: название марки, госномер, телефон клиента и номер водительского удостоверения не могут повторяться.",
|
|
|
+ "CHECK: год выпуска 1990-2030, стоимость аренды больше 0, сумма договора не отрицательная, дата окончания не раньше даты начала.",
|
|
|
+ "CHECK по статусам ограничивает значения: автомобиль может быть только «Свободен», «В аренде» или «На ТО», договор - «Активна» или «Завершена».",
|
|
|
+ "Индексы idx_car_brand_id, idx_car_status, idx_rental_car_id, idx_rental_cli_id, idx_rental_dates, idx_rental_status ускоряют поиск и отчеты."
|
|
|
+ ]), 0, content.RowCount++);
|
|
|
+ content.Controls.Add(CreateBulletsCard("Представления для безопасной отчетности", [
|
|
|
+ "v_rentals_full показывает договоры с ФИО клиента, телефоном, номером автомобиля, маркой, датами, суммой и статусом.",
|
|
|
+ "v_available_cars ограничивает выборку только свободными автомобилями.",
|
|
|
+ "v_client_stats агрегирует количество аренд и сумму затрат по клиентам.",
|
|
|
+ "Отчеты приложения строятся по представлениям и агрегатным запросам, без ручной сборки данных пользователем."
|
|
|
+ ]), 0, content.RowCount++);
|
|
|
+ content.Controls.Add(CreateSectionTitle("Разграничение доступа"), 0, content.RowCount++);
|
|
|
+ content.Controls.Add(CreateBulletsCard("Роли PostgreSQL из проекта", [
|
|
|
+ "rent_admin - полный доступ ко всем таблицам и последовательностям.",
|
|
|
+ "rent_manager - SELECT, INSERT, UPDATE для client, car, rental; SELECT по brand и отчетным VIEW.",
|
|
|
+ "rent_viewer - только SELECT, подходит для аналитика и преподавательской проверки.",
|
|
|
+ "Row Level Security включен для rental: политики разрешают полный доступ администратору и менеджеру, а просмотр можно ограничивать отдельной SELECT-политикой.",
|
|
|
+ "В приложении дополнительно используются роли admin, manager, viewer в таблице app_user, чтобы управлять доступностью кнопок и операций интерфейса."
|
|
|
+ ]), 0, content.RowCount++);
|
|
|
+ content.Controls.Add(CreateSectionTitle("Защита на уровне приложения"), 0, content.RowCount++);
|
|
|
+ content.Controls.Add(CreateBulletsCard("Меры, реализованные в Windows Forms", [
|
|
|
+ "Авторизация по логину и паролю перед открытием главного окна.",
|
|
|
+ "Пароли хранятся как PBKDF2-SHA256 хеш с индивидуальной солью, а не в открытом виде.",
|
|
|
+ "Права интерфейса зависят от роли: viewer не может добавлять, редактировать и удалять записи.",
|
|
|
+ "Все запросы выполняются через параметры Npgsql, что защищает от SQL-инъекций.",
|
|
|
+ "Строка подключения вынесена в dbsettings.json или переменную AUTORENT_CONNECTION_STRING.",
|
|
|
+ "dbsettings.json добавлен в .gitignore, чтобы пароль от БД не попадал в репозиторий.",
|
|
|
+ "При запуске приложение синхронизирует SERIAL-последовательности с MAX(id), чтобы исключить duplicate key после ручного импорта данных."
|
|
|
+ ]), 0, content.RowCount++);
|
|
|
+ content.Controls.Add(CreateSectionTitle("Аудит операций"), 0, content.RowCount++);
|
|
|
+ content.Controls.Add(CreateBulletsCard("Логирование изменений", [
|
|
|
+ "Таблица audit_log хранит время операции, пользователя, действие, таблицу, ID записи и детали изменения.",
|
|
|
+ "Триггеры AFTER INSERT OR UPDATE OR DELETE установлены на brand, car, client и rental.",
|
|
|
+ "Текущий пользователь приложения сохраняется в app_context и попадает в журнал аудита.",
|
|
|
+ "Отдельная вкладка «Журнал операций» показывает последние операции из audit_log.",
|
|
|
+ "Такой аудит помогает доказать, кто и когда изменял автомобили, клиентов и договоры аренды."
|
|
|
+ ]), 0, content.RowCount++);
|
|
|
+ content.Controls.Add(CreateSqlCard(), 0, content.RowCount++);
|
|
|
+ scroll.Controls.Add(content);
|
|
|
+ page.Controls.Add(scroll);
|
|
|
+ _tabs.TabPages.Add(page);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Label CreateSectionTitle(string text) => new()
|
|
|
+ {
|
|
|
+ Text = text,
|
|
|
+ Dock = DockStyle.Top,
|
|
|
+ Height = 34,
|
|
|
+ ForeColor = Color.FromArgb(30, 41, 59),
|
|
|
+ Font = new Font("Segoe UI", 13F, FontStyle.Bold),
|
|
|
+ Margin = new Padding(0, 8, 0, 4)
|
|
|
+ };
|
|
|
+
|
|
|
+ private static Control CreateRolesPanel()
|
|
|
+ {
|
|
|
+ var layout = new TableLayoutPanel
|
|
|
+ {
|
|
|
+ Dock = DockStyle.Top,
|
|
|
+ AutoSize = true,
|
|
|
+ ColumnCount = 3,
|
|
|
+ Margin = new Padding(0, 0, 0, 12)
|
|
|
+ };
|
|
|
+ layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.33F));
|
|
|
+ layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.33F));
|
|
|
+ layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.33F));
|
|
|
+
|
|
|
+ layout.Controls.Add(CreateRoleCard("admin", "Администратор", "Полный доступ", "Управляет структурой БД, справочниками, пользователями, аудитом, резервным копированием и контролем целостности.", Color.FromArgb(185, 28, 28)), 0, 0);
|
|
|
+ layout.Controls.Add(CreateRoleCard("manager", "Менеджер проката", "Рабочий CRUD", "Вводит клиентов, автомобили и договоры аренды, редактирует рабочие данные, формирует отчеты.", Color.FromArgb(21, 91, 158)), 1, 0);
|
|
|
+ layout.Controls.Add(CreateRoleCard("viewer", "Аналитик", "Только чтение", "Просматривает данные, строит отчеты и проверяет показатели без права изменения записей.", Color.FromArgb(22, 101, 52)), 2, 0);
|
|
|
+ return layout;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Panel CreateRoleCard(string login, string title, string access, string description, Color accent)
|
|
|
+ {
|
|
|
+ var panel = CreateBaseCard(174);
|
|
|
+ panel.Margin = new Padding(0, 0, 10, 0);
|
|
|
+ panel.Padding = new Padding(14);
|
|
|
+
|
|
|
+ var loginLabel = new Label
|
|
|
+ {
|
|
|
+ Text = login,
|
|
|
+ ForeColor = accent,
|
|
|
+ Font = new Font("Segoe UI", 11F, FontStyle.Bold),
|
|
|
+ Dock = DockStyle.Top,
|
|
|
+ Height = 26
|
|
|
+ };
|
|
|
+ var titleLabel = new Label
|
|
|
+ {
|
|
|
+ Text = title,
|
|
|
+ ForeColor = Color.FromArgb(15, 23, 42),
|
|
|
+ Font = new Font("Segoe UI", 12F, FontStyle.Bold),
|
|
|
+ Dock = DockStyle.Top,
|
|
|
+ Height = 30
|
|
|
+ };
|
|
|
+ var accessLabel = new Label
|
|
|
+ {
|
|
|
+ Text = access,
|
|
|
+ ForeColor = Color.White,
|
|
|
+ BackColor = accent,
|
|
|
+ Font = new Font("Segoe UI", 9F, FontStyle.Bold),
|
|
|
+ AutoSize = false,
|
|
|
+ Height = 26,
|
|
|
+ Dock = DockStyle.Top,
|
|
|
+ TextAlign = ContentAlignment.MiddleCenter,
|
|
|
+ Margin = new Padding(0, 0, 0, 8)
|
|
|
+ };
|
|
|
+ var body = new Label
|
|
|
+ {
|
|
|
+ Text = description,
|
|
|
+ ForeColor = Color.FromArgb(71, 85, 105),
|
|
|
+ Font = new Font("Segoe UI", 9.5F),
|
|
|
+ Dock = DockStyle.Fill
|
|
|
+ };
|
|
|
+
|
|
|
+ panel.Controls.Add(body);
|
|
|
+ panel.Controls.Add(accessLabel);
|
|
|
+ panel.Controls.Add(titleLabel);
|
|
|
+ panel.Controls.Add(loginLabel);
|
|
|
+ return panel;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Control CreateProtectionMatrix()
|
|
|
+ {
|
|
|
+ var layout = new TableLayoutPanel
|
|
|
+ {
|
|
|
+ Dock = DockStyle.Top,
|
|
|
+ AutoSize = true,
|
|
|
+ ColumnCount = 2,
|
|
|
+ Margin = new Padding(0, 0, 0, 12)
|
|
|
+ };
|
|
|
+ layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
|
|
|
+ layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
|
|
|
+
|
|
|
+ layout.Controls.Add(CreateBulletsCard("1. Идентификация и аутентификация", [
|
|
|
+ "Пользователь входит по логину и паролю.",
|
|
|
+ "Учетные записи хранятся в app_user.",
|
|
|
+ "Пароль проверяется по хешу PBKDF2-SHA256 с солью."
|
|
|
+ ]), 0, 0);
|
|
|
+ layout.Controls.Add(CreateBulletsCard("2. Авторизация и роли", [
|
|
|
+ "admin, manager, viewer управляют доступом в интерфейсе.",
|
|
|
+ "rent_admin, rent_manager, rent_viewer описывают права на уровне PostgreSQL.",
|
|
|
+ "Viewer не получает кнопки изменения данных."
|
|
|
+ ]), 1, 0);
|
|
|
+ layout.Controls.Add(CreateBulletsCard("3. Целостность данных", [
|
|
|
+ "PK, FK, UNIQUE, CHECK защищают структуру БД.",
|
|
|
+ "ON DELETE RESTRICT запрещает удалить запись, если она участвует в договоре.",
|
|
|
+ "Последовательности синхронизируются после импорта."
|
|
|
+ ]), 0, 1);
|
|
|
+ layout.Controls.Add(CreateBulletsCard("4. Контроль и аудит", [
|
|
|
+ "Триггеры фиксируют INSERT, UPDATE и DELETE.",
|
|
|
+ "audit_log хранит пользователя, таблицу, действие и детали.",
|
|
|
+ "Журнал доступен в отдельной вкладке приложения."
|
|
|
+ ]), 1, 1);
|
|
|
+ return layout;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Panel CreateBulletsCard(string title, string[] bullets)
|
|
|
+ {
|
|
|
+ var panel = CreateBaseCard(Math.Max(112, 48 + bullets.Length * 26));
|
|
|
+ panel.Padding = new Padding(14);
|
|
|
+ panel.Margin = new Padding(0, 0, 10, 12);
|
|
|
+
|
|
|
+ var titleLabel = new Label
|
|
|
+ {
|
|
|
+ Text = title,
|
|
|
+ Dock = DockStyle.Top,
|
|
|
+ Height = 30,
|
|
|
+ ForeColor = Color.FromArgb(15, 23, 42),
|
|
|
+ Font = new Font("Segoe UI", 11F, FontStyle.Bold)
|
|
|
+ };
|
|
|
+
|
|
|
+ var body = new Label
|
|
|
+ {
|
|
|
+ Text = string.Join(Environment.NewLine, bullets.Select(x => " - " + x)),
|
|
|
+ Dock = DockStyle.Fill,
|
|
|
+ ForeColor = Color.FromArgb(51, 65, 85),
|
|
|
+ Font = new Font("Segoe UI", 9.6F),
|
|
|
+ AutoEllipsis = false
|
|
|
+ };
|
|
|
+
|
|
|
+ panel.Controls.Add(body);
|
|
|
+ panel.Controls.Add(titleLabel);
|
|
|
+ return panel;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Panel CreateSqlCard()
|
|
|
+ {
|
|
|
+ var panel = CreateBaseCard(270);
|
|
|
+ panel.Padding = new Padding(14);
|
|
|
+ panel.Margin = new Padding(0, 0, 0, 12);
|
|
|
+
|
|
|
+ var title = new Label
|
|
|
+ {
|
|
|
+ Text = "Ключевые SQL-механизмы защиты",
|
|
|
+ Dock = DockStyle.Top,
|
|
|
+ Height = 30,
|
|
|
+ ForeColor = Color.FromArgb(15, 23, 42),
|
|
|
+ Font = new Font("Segoe UI", 11F, FontStyle.Bold)
|
|
|
+ };
|
|
|
+
|
|
|
+ var sql = new RichTextBox
|
|
|
+ {
|
|
|
+ Dock = DockStyle.Fill,
|
|
|
+ ReadOnly = true,
|
|
|
+ BorderStyle = BorderStyle.None,
|
|
|
+ BackColor = Color.FromArgb(248, 250, 252),
|
|
|
+ ForeColor = Color.FromArgb(30, 41, 59),
|
|
|
+ Font = new Font("Consolas", 9F),
|
|
|
+ Text = """
|
|
|
+ -- Ограничения целостности
|
|
|
+ CONSTRAINT uq_client_phone UNIQUE,
|
|
|
+ CONSTRAINT chk_rental_dates CHECK (end_date >= start_date),
|
|
|
+ CONSTRAINT fk_rental_client REFERENCES client(client_id) ON DELETE RESTRICT
|
|
|
+
|
|
|
+ -- Роли и права
|
|
|
+ CREATE ROLE rent_admin NOLOGIN;
|
|
|
+ CREATE ROLE rent_manager NOLOGIN;
|
|
|
+ CREATE ROLE rent_viewer NOLOGIN;
|
|
|
+ GRANT SELECT, INSERT, UPDATE ON client, car, rental TO rent_manager;
|
|
|
+ GRANT SELECT ON ALL TABLES IN SCHEMA public TO rent_viewer;
|
|
|
+
|
|
|
+ -- Row Level Security
|
|
|
+ ALTER TABLE rental ENABLE ROW LEVEL SECURITY;
|
|
|
+ CREATE POLICY manager_access ON rental
|
|
|
+ FOR ALL TO rent_manager USING (true) WITH CHECK (true);
|
|
|
+
|
|
|
+ -- Аудит
|
|
|
+ CREATE TRIGGER trg_audit_client
|
|
|
+ AFTER INSERT OR UPDATE OR DELETE ON client
|
|
|
+ FOR EACH ROW EXECUTE FUNCTION fn_autorent_audit('client_id');
|
|
|
+ """
|
|
|
+ };
|
|
|
+
|
|
|
+ panel.Controls.Add(sql);
|
|
|
+ panel.Controls.Add(title);
|
|
|
+ return panel;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Panel CreateBaseCard(int height) => new()
|
|
|
+ {
|
|
|
+ Height = height,
|
|
|
+ Dock = DockStyle.Top,
|
|
|
+ BackColor = Color.White,
|
|
|
+ BorderStyle = BorderStyle.FixedSingle
|
|
|
+ };
|
|
|
+
|
|
|
+ private void CreateAuditTab()
|
|
|
+ {
|
|
|
+ var page = new TabPage("Журнал операций");
|
|
|
+ var root = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2 };
|
|
|
+ root.RowStyles.Add(new RowStyle(SizeType.Absolute, 48));
|
|
|
+ root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
|
|
|
+
|
|
|
+ var bar = new FlowLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(8) };
|
|
|
+ var refresh = new Button { Text = "Обновить", Width = 105, Height = 30 };
|
|
|
+ refresh.Click += (_, _) => LoadAudit();
|
|
|
+ bar.Controls.Add(refresh);
|
|
|
+
|
|
|
+ var grid = new DataGridView
|
|
|
+ {
|
|
|
+ Dock = DockStyle.Fill,
|
|
|
+ ReadOnly = true,
|
|
|
+ AllowUserToAddRows = false,
|
|
|
+ AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
|
|
|
+ BackgroundColor = Color.White
|
|
|
+ };
|
|
|
+ _grids["audit"] = grid;
|
|
|
+
|
|
|
+ root.Controls.Add(bar, 0, 0);
|
|
|
+ root.Controls.Add(grid, 0, 1);
|
|
|
+ page.Controls.Add(root);
|
|
|
+ _tabs.TabPages.Add(page);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void LoadAll()
|
|
|
+ {
|
|
|
+ foreach (var table in new[] { "clients", "cars", "brands", "rentals" })
|
|
|
+ LoadTable(table);
|
|
|
+ if (_session.Role != "viewer")
|
|
|
+ {
|
|
|
+ LoadReports();
|
|
|
+ LoadAudit();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void LoadTable(string table)
|
|
|
+ {
|
|
|
+ var q = _searchBoxes.TryGetValue(table, out var box) ? box.Text.Trim() : "";
|
|
|
+ var like = $"%{q}%";
|
|
|
+ var data = table switch
|
|
|
+ {
|
|
|
+ "clients" => _db.Query("""
|
|
|
+ SELECT client_id AS ID, full_name AS ФИО, phone AS Телефон, address AS Адрес,
|
|
|
+ license_no AS ВУ, date(created_at) AS Регистрация
|
|
|
+ FROM client
|
|
|
+ WHERE @q = '' OR full_name LIKE @like OR phone LIKE @like OR address LIKE @like OR license_no LIKE @like
|
|
|
+ ORDER BY client_id DESC
|
|
|
+ """, new() { ["@q"] = q, ["@like"] = like }),
|
|
|
+ "cars" => _db.Query("""
|
|
|
+ SELECT ca.car_id AS ID, ca.license_plate AS Госномер, b.name AS Марка, ca.year AS Год,
|
|
|
+ ca.daily_rate AS Цена_день, ca.status AS Статус
|
|
|
+ FROM car ca JOIN brand b ON b.brand_id = ca.brand_id
|
|
|
+ WHERE @q = '' OR ca.license_plate LIKE @like OR b.name LIKE @like OR ca.status LIKE @like
|
|
|
+ ORDER BY ca.car_id DESC
|
|
|
+ """, new() { ["@q"] = q, ["@like"] = like }),
|
|
|
+ "brands" => _db.Query("""
|
|
|
+ SELECT b.brand_id AS ID, b.name AS Марка, b.country AS Страна, COUNT(c.car_id) AS Автомобилей
|
|
|
+ FROM brand b LEFT JOIN car c ON c.brand_id = b.brand_id
|
|
|
+ WHERE @q = '' OR b.name LIKE @like OR b.country LIKE @like
|
|
|
+ GROUP BY b.brand_id, b.name, b.country
|
|
|
+ ORDER BY b.brand_id DESC
|
|
|
+ """, new() { ["@q"] = q, ["@like"] = like }),
|
|
|
+ "rentals" => _db.Query("""
|
|
|
+ SELECT r.rental_id AS ID, c.full_name AS Клиент, ca.license_plate AS Авто, b.name AS Марка,
|
|
|
+ r.start_date AS Начало, r.end_date AS Конец, r.actual_return AS Возврат,
|
|
|
+ r.total_cost AS Сумма, r.status AS Статус
|
|
|
+ FROM rental r
|
|
|
+ JOIN client c ON c.client_id = r.client_id
|
|
|
+ JOIN car ca ON ca.car_id = r.car_id
|
|
|
+ JOIN brand b ON b.brand_id = ca.brand_id
|
|
|
+ WHERE @q = '' OR c.full_name LIKE @like OR ca.license_plate LIKE @like OR b.name LIKE @like OR r.status LIKE @like
|
|
|
+ ORDER BY r.rental_id DESC
|
|
|
+ """, new() { ["@q"] = q, ["@like"] = like }),
|
|
|
+ _ => new DataTable()
|
|
|
+ };
|
|
|
+ _grids[table].DataSource = data;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void EditSelected(string table)
|
|
|
+ {
|
|
|
+ var row = _grids[table].CurrentRow;
|
|
|
+ if (row?.Cells["ID"].Value is null) return;
|
|
|
+ EditRecord(table, Convert.ToInt32(row.Cells["ID"].Value));
|
|
|
+ }
|
|
|
+
|
|
|
+ private void EditRecord(string table, int? id)
|
|
|
+ {
|
|
|
+ try
|
|
|
+ {
|
|
|
+ using var form = new RecordForm(_db, table, id);
|
|
|
+ if (form.ShowDialog(this) != DialogResult.OK) return;
|
|
|
+ LoadTable(table);
|
|
|
+ LoadReports();
|
|
|
+ LoadAudit();
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ MessageBox.Show(ex.Message, "Ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void DeleteSelected(string table)
|
|
|
+ {
|
|
|
+ var row = _grids[table].CurrentRow;
|
|
|
+ if (row?.Cells["ID"].Value is null) return;
|
|
|
+ var id = Convert.ToInt32(row.Cells["ID"].Value);
|
|
|
+ if (MessageBox.Show("Удалить выбранную запись?", "Подтверждение", MessageBoxButtons.YesNo, MessageBoxIcon.Warning) != DialogResult.Yes)
|
|
|
+ return;
|
|
|
+
|
|
|
+ try
|
|
|
+ {
|
|
|
+ var pk = table switch { "clients" => "client_id", "cars" => "car_id", "brands" => "brand_id", "rentals" => "rental_id", _ => "id" };
|
|
|
+ var dbTable = table.TrimEnd('s');
|
|
|
+ if (table == "clients") dbTable = "client";
|
|
|
+ if (table == "cars") dbTable = "car";
|
|
|
+ if (table == "brands") dbTable = "brand";
|
|
|
+ if (table == "rentals") dbTable = "rental";
|
|
|
+ _db.ExecuteNonQuery($"DELETE FROM {dbTable} WHERE {pk} = @id", new() { ["@id"] = id });
|
|
|
+ LoadTable(table);
|
|
|
+ LoadReports();
|
|
|
+ LoadAudit();
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ MessageBox.Show("Удаление невозможно: запись используется в связанных таблицах или нарушает ограничения.\n\n" + ex.Message,
|
|
|
+ "Защита целостности", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void LoadAudit()
|
|
|
+ {
|
|
|
+ _grids["audit"].DataSource = _db.Query("""
|
|
|
+ SELECT audit_id AS ID, changed_at AS Время, user_login AS Пользователь,
|
|
|
+ action AS Операция, entity AS Таблица, entity_id AS Запись, details AS Детали
|
|
|
+ FROM audit_log
|
|
|
+ ORDER BY audit_id DESC
|
|
|
+ LIMIT 300
|
|
|
+ """);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void LoadReports()
|
|
|
+ {
|
|
|
+ var box = FindReportBox();
|
|
|
+ box.Text = BuildReportText();
|
|
|
+ }
|
|
|
+
|
|
|
+ private string BuildReportText()
|
|
|
+ {
|
|
|
+ var stats = _db.Query("""
|
|
|
+ SELECT
|
|
|
+ (SELECT COUNT(*) FROM client) AS clients,
|
|
|
+ (SELECT COUNT(*) FROM car) AS cars,
|
|
|
+ (SELECT COUNT(*) FROM rental WHERE status = 'Активна') AS active_rentals,
|
|
|
+ (SELECT COALESCE(SUM(total_cost),0) FROM rental WHERE status = 'Завершена') AS revenue
|
|
|
+ """).Rows[0];
|
|
|
+ var sb = new StringBuilder();
|
|
|
+ sb.AppendLine("ОТЧЕТ ПО СИСТЕМЕ АВТОПРОКАТА");
|
|
|
+ sb.AppendLine($"Дата формирования: {DateTime.Now:dd.MM.yyyy HH:mm}");
|
|
|
+ sb.AppendLine($"Пользователь: {_session.FullName} ({RoleName(_session.Role)})");
|
|
|
+ sb.AppendLine(new string('=', 72));
|
|
|
+ sb.AppendLine($"Клиентов: {stats["clients"]}");
|
|
|
+ sb.AppendLine($"Автомобилей: {stats["cars"]}");
|
|
|
+ sb.AppendLine($"Активных аренд: {stats["active_rentals"]}");
|
|
|
+ sb.AppendLine($"Выручка по завершенным арендам: {Convert.ToDecimal(stats["revenue"]).ToString("N2", CultureInfo.GetCultureInfo("ru-RU"))} руб.");
|
|
|
+ sb.AppendLine();
|
|
|
+
|
|
|
+ AppendTable(sb, "Активные договоры", _db.Query("SELECT * FROM v_rentals_full WHERE status='Активна' ORDER BY start_date DESC"));
|
|
|
+ AppendTable(sb, "Статистика по клиентам", _db.Query("SELECT full_name, total_rentals, total_spent FROM v_client_stats ORDER BY total_spent DESC"));
|
|
|
+ AppendTable(sb, "Свободные автомобили", _db.Query("SELECT * FROM v_available_cars ORDER BY daily_rate DESC"));
|
|
|
+ return sb.ToString();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void AppendTable(StringBuilder sb, string title, DataTable table)
|
|
|
+ {
|
|
|
+ sb.AppendLine(title);
|
|
|
+ sb.AppendLine(new string('-', title.Length));
|
|
|
+ if (table.Rows.Count == 0)
|
|
|
+ {
|
|
|
+ sb.AppendLine("Нет данных.");
|
|
|
+ sb.AppendLine();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ sb.AppendLine(string.Join(" | ", table.Columns.Cast<DataColumn>().Select(c => c.ColumnName)));
|
|
|
+ foreach (DataRow row in table.Rows)
|
|
|
+ sb.AppendLine(string.Join(" | ", row.ItemArray.Select(v => Convert.ToString(v))));
|
|
|
+ sb.AppendLine();
|
|
|
+ }
|
|
|
+
|
|
|
+ private void SaveReport(string format)
|
|
|
+ {
|
|
|
+ var reportsDir = Path.Combine(AppContext.BaseDirectory, "Reports");
|
|
|
+ Directory.CreateDirectory(reportsDir);
|
|
|
+ var path = Path.Combine(reportsDir, $"autorent_report_{DateTime.Now:yyyyMMdd_HHmmss}.{format}");
|
|
|
+ var text = BuildReportText();
|
|
|
+
|
|
|
+ if (format == "html")
|
|
|
+ {
|
|
|
+ var html = "<!doctype html><meta charset=\"utf-8\"><title>Отчет Автопрокат</title>" +
|
|
|
+ "<style>body{font-family:Segoe UI,Arial;margin:32px}pre{white-space:pre-wrap;font:14px Consolas}</style>" +
|
|
|
+ "<h1>Отчет по системе Автопрокат</h1><pre>" + System.Net.WebUtility.HtmlEncode(text) + "</pre>";
|
|
|
+ File.WriteAllText(path, html, Encoding.UTF8);
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ File.WriteAllText(path, text, Encoding.UTF8);
|
|
|
+ }
|
|
|
+
|
|
|
+ MessageBox.Show($"Отчет сохранен:\n{path}", "Готово", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
|
|
+ try { Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); } catch { }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void ExportCsv(string table)
|
|
|
+ {
|
|
|
+ if (_grids[table].DataSource is not DataTable data) return;
|
|
|
+ var reportsDir = Path.Combine(AppContext.BaseDirectory, "Reports");
|
|
|
+ Directory.CreateDirectory(reportsDir);
|
|
|
+ var path = Path.Combine(reportsDir, $"{table}_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
|
|
|
+ var sb = new StringBuilder();
|
|
|
+ sb.AppendLine(string.Join(";", data.Columns.Cast<DataColumn>().Select(c => EscapeCsv(c.ColumnName))));
|
|
|
+ foreach (DataRow row in data.Rows)
|
|
|
+ sb.AppendLine(string.Join(";", row.ItemArray.Select(v => EscapeCsv(Convert.ToString(v) ?? ""))));
|
|
|
+ File.WriteAllText(path, sb.ToString(), new UTF8Encoding(true));
|
|
|
+ MessageBox.Show($"CSV сохранен:\n{path}", "Экспорт", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static string EscapeCsv(string value) => "\"" + value.Replace("\"", "\"\"") + "\"";
|
|
|
+
|
|
|
+ private TextBox FindReportBox()
|
|
|
+ {
|
|
|
+ return _tabs.TabPages.Cast<TabPage>().SelectMany(p => p.Controls.Find("reportBox", true)).OfType<TextBox>().First();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static string RoleName(string role) => role switch
|
|
|
+ {
|
|
|
+ "admin" => "Администратор",
|
|
|
+ "manager" => "Менеджер",
|
|
|
+ "viewer" => "Аналитик",
|
|
|
+ _ => role
|
|
|
+ };
|
|
|
+
|
|
|
+ private string SecurityText() => """
|
|
|
+ Пользователи и обязанности
|
|
|
+
|
|
|
+ 1. admin / Администратор БД
|
|
|
+ Полный доступ к справочникам, автомобилям, клиентам, договорам, отчетам и журналу аудита.
|
|
|
+ Отвечает за структуру БД, учетные записи, резервное копирование и контроль целостности.
|
|
|
+
|
|
|
+ 2. manager / Менеджер проката
|
|
|
+ Вводит клиентов, автомобили и договоры аренды, редактирует рабочие данные, формирует отчеты.
|
|
|
+ Не управляет ролями и справочником марок без администратора.
|
|
|
+
|
|
|
+ 3. viewer / Аналитик
|
|
|
+ Имеет доступ только к просмотру данных и формированию отчетов. Изменение и удаление записей запрещено.
|
|
|
+
|
|
|
+ Защита на уровне приложения
|
|
|
+
|
|
|
+ - вход по логину и паролю;
|
|
|
+ - хранение паролей в виде PBKDF2-SHA256 хеша с индивидуальной солью;
|
|
|
+ - разграничение прав по ролям admin, manager, viewer;
|
|
|
+ - запрет кнопок добавления, редактирования и удаления для роли viewer;
|
|
|
+ - параметризованные SQL-запросы вместо конкатенации пользовательского ввода;
|
|
|
+ - обработка ошибок ограничений БД без раскрытия лишней служебной информации пользователю.
|
|
|
+
|
|
|
+ Защита на уровне базы данных
|
|
|
+
|
|
|
+ - первичные и внешние ключи;
|
|
|
+ - UNIQUE для госномера, телефона, водительского удостоверения и названия марки;
|
|
|
+ - CHECK для статусов, положительной цены, корректного года выпуска и дат аренды;
|
|
|
+ - ON DELETE RESTRICT для защиты связанных договоров от случайного удаления;
|
|
|
+ - представления v_rentals_full, v_available_cars, v_client_stats для отчетности;
|
|
|
+ - триггеры INSERT/UPDATE/DELETE на основных таблицах пишут операции в audit_log;
|
|
|
+ - PRAGMA foreign_keys = ON и WAL-журналирование при работе приложения.
|
|
|
+ """;
|
|
|
+}
|
|
|
+
|
|
|
+internal sealed class RecordForm : Form
|
|
|
+{
|
|
|
+ private readonly Database _db;
|
|
|
+ private readonly string _table;
|
|
|
+ private readonly int? _id;
|
|
|
+ private readonly Dictionary<string, Control> _fields = new();
|
|
|
+
|
|
|
+ public RecordForm(Database db, string table, int? id)
|
|
|
+ {
|
|
|
+ _db = db;
|
|
|
+ _table = table;
|
|
|
+ _id = id;
|
|
|
+ Text = id is null ? "Добавить запись" : "Редактировать запись";
|
|
|
+ StartPosition = FormStartPosition.CenterParent;
|
|
|
+ FormBorderStyle = FormBorderStyle.FixedDialog;
|
|
|
+ MaximizeBox = false;
|
|
|
+ MinimizeBox = false;
|
|
|
+ ClientSize = new Size(520, table == "rentals" ? 430 : 350);
|
|
|
+ Font = new Font("Segoe UI", 10F);
|
|
|
+
|
|
|
+ BuildForm();
|
|
|
+ }
|
|
|
+
|
|
|
+ private void BuildForm()
|
|
|
+ {
|
|
|
+ var panel = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(16), ColumnCount = 2, AutoScroll = true };
|
|
|
+ panel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 170));
|
|
|
+ panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
|
|
+
|
|
|
+ var data = _id is null ? null : LoadRecord();
|
|
|
+ foreach (var field in Fields())
|
|
|
+ AddField(panel, field, data);
|
|
|
+
|
|
|
+ var buttons = new FlowLayoutPanel { FlowDirection = FlowDirection.RightToLeft, Dock = DockStyle.Fill };
|
|
|
+ var save = new Button { Text = "Сохранить", Width = 110, Height = 32 };
|
|
|
+ var cancel = new Button { Text = "Отмена", Width = 90, Height = 32 };
|
|
|
+ save.Click += (_, _) => Save();
|
|
|
+ cancel.Click += (_, _) => Close();
|
|
|
+ buttons.Controls.AddRange([save, cancel]);
|
|
|
+ panel.Controls.Add(new Label(), 0, panel.RowCount);
|
|
|
+ panel.Controls.Add(buttons, 1, panel.RowCount++);
|
|
|
+ Controls.Add(panel);
|
|
|
+ AcceptButton = save;
|
|
|
+ }
|
|
|
+
|
|
|
+ private DataRow LoadRecord()
|
|
|
+ {
|
|
|
+ var (dbTable, pk) = DbTable();
|
|
|
+ return _db.Query($"SELECT * FROM {dbTable} WHERE {pk}=@id", new() { ["@id"] = _id }).Rows[0];
|
|
|
+ }
|
|
|
+
|
|
|
+ private IEnumerable<(string Name, string Label, string Type)> Fields() => _table switch
|
|
|
+ {
|
|
|
+ "clients" => [("full_name", "ФИО", "text"), ("phone", "Телефон", "text"), ("address", "Адрес", "text"), ("license_no", "Водительское удостоверение", "text")],
|
|
|
+ "cars" => [("license_plate", "Госномер", "text"), ("brand_id", "Марка", "brand"), ("year", "Год выпуска", "number"), ("daily_rate", "Цена за день", "money"), ("status", "Статус", "car_status")],
|
|
|
+ "brands" => [("name", "Марка", "text"), ("country", "Страна", "text")],
|
|
|
+ "rentals" => [("client_id", "Клиент", "client"), ("car_id", "Автомобиль", "car"), ("start_date", "Дата начала", "date"), ("end_date", "Дата окончания", "date"), ("actual_return", "Фактический возврат", "date"), ("total_cost", "Сумма", "money"), ("status", "Статус", "rental_status")],
|
|
|
+ _ => []
|
|
|
+ };
|
|
|
+
|
|
|
+ private void AddField(TableLayoutPanel panel, (string Name, string Label, string Type) field, DataRow? data)
|
|
|
+ {
|
|
|
+ panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 42));
|
|
|
+ panel.Controls.Add(new Label { Text = field.Label, AutoSize = true, Anchor = AnchorStyles.Left }, 0, panel.RowCount);
|
|
|
+
|
|
|
+ Control input = field.Type switch
|
|
|
+ {
|
|
|
+ "brand" => Combo("SELECT brand_id, name FROM brand ORDER BY name", "brand_id", data),
|
|
|
+ "client" => Combo("SELECT client_id, full_name FROM client ORDER BY full_name", "client_id", data),
|
|
|
+ "car" => Combo("SELECT car_id, license_plate || ' - ' || (SELECT name FROM brand WHERE brand_id=car.brand_id) FROM car ORDER BY license_plate", "car_id", data),
|
|
|
+ "car_status" => ComboValues(["Свободен", "В аренде", "На ТО"], field.Name, data),
|
|
|
+ "rental_status" => ComboValues(["Активна", "Завершена"], field.Name, data),
|
|
|
+ "date" => new DateTimePicker { Width = 280, Format = DateTimePickerFormat.Short, ShowCheckBox = field.Name == "actual_return" },
|
|
|
+ _ => new TextBox { Width = 280 }
|
|
|
+ };
|
|
|
+
|
|
|
+ if (input is TextBox tb)
|
|
|
+ tb.Text = data is null ? "" : Convert.ToString(data[field.Name]);
|
|
|
+ if (input is DateTimePicker dtp)
|
|
|
+ {
|
|
|
+ var value = data is null ? "" : Convert.ToString(data[field.Name]);
|
|
|
+ if (DateTime.TryParse(value, out var date))
|
|
|
+ {
|
|
|
+ dtp.Value = date;
|
|
|
+ dtp.Checked = true;
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ dtp.Value = DateTime.Today;
|
|
|
+ dtp.Checked = field.Name != "actual_return";
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ input.Anchor = AnchorStyles.Left;
|
|
|
+ _fields[field.Name] = input;
|
|
|
+ panel.Controls.Add(input, 1, panel.RowCount++);
|
|
|
+ }
|
|
|
+
|
|
|
+ private ComboBox Combo(string sql, string fieldName, DataRow? data)
|
|
|
+ {
|
|
|
+ var combo = new ComboBox { Width = 280, DropDownStyle = ComboBoxStyle.DropDownList };
|
|
|
+ var table = _db.Query(sql);
|
|
|
+ combo.DataSource = table;
|
|
|
+ combo.ValueMember = table.Columns[0].ColumnName;
|
|
|
+ combo.DisplayMember = table.Columns[1].ColumnName;
|
|
|
+ if (data is not null && data[fieldName] != DBNull.Value)
|
|
|
+ combo.SelectedValue = Convert.ToInt32(data[fieldName]);
|
|
|
+ return combo;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static ComboBox ComboValues(string[] values, string fieldName, DataRow? data)
|
|
|
+ {
|
|
|
+ var combo = new ComboBox { Width = 280, DropDownStyle = ComboBoxStyle.DropDownList };
|
|
|
+ combo.Items.AddRange(values);
|
|
|
+ combo.SelectedItem = data is null ? values[0] : Convert.ToString(data[fieldName]);
|
|
|
+ return combo;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void Save()
|
|
|
+ {
|
|
|
+ try
|
|
|
+ {
|
|
|
+ ValidateRequired();
|
|
|
+ var (dbTable, pk) = DbTable();
|
|
|
+ var fields = Fields().Select(f => f.Name).ToArray();
|
|
|
+ var values = fields.ToDictionary(f => "@" + f, ReadValue);
|
|
|
+
|
|
|
+ if (_id is null)
|
|
|
+ {
|
|
|
+ var sql = $"INSERT INTO {dbTable}({string.Join(",", fields)}) VALUES({string.Join(",", values.Keys)})";
|
|
|
+ _db.ExecuteNonQuery(sql, values);
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ values["@id"] = _id;
|
|
|
+ var set = string.Join(",", fields.Select(f => $"{f}=@{f}"));
|
|
|
+ _db.ExecuteNonQuery($"UPDATE {dbTable} SET {set} WHERE {pk}=@id", values);
|
|
|
+ }
|
|
|
+
|
|
|
+ DialogResult = DialogResult.OK;
|
|
|
+ Close();
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ MessageBox.Show(ex.Message, "Ошибка сохранения", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void ValidateRequired()
|
|
|
+ {
|
|
|
+ foreach (var field in Fields())
|
|
|
+ {
|
|
|
+ if (_fields[field.Name] is TextBox tb && string.IsNullOrWhiteSpace(tb.Text) && field.Name is "full_name" or "license_plate" or "name")
|
|
|
+ throw new InvalidOperationException($"Заполните поле «{field.Label}».");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private object? ReadValue(string parameterName)
|
|
|
+ {
|
|
|
+ var name = parameterName.TrimStart('@');
|
|
|
+ var control = _fields[name];
|
|
|
+ return control switch
|
|
|
+ {
|
|
|
+ TextBox tb when name is "year" => int.TryParse(tb.Text, out var i) ? i : null,
|
|
|
+ TextBox tb when name is "daily_rate" or "total_cost" => decimal.TryParse(tb.Text.Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null,
|
|
|
+ TextBox tb => string.IsNullOrWhiteSpace(tb.Text) ? null : tb.Text.Trim(),
|
|
|
+ ComboBox cb => cb.SelectedValue is DataRowView ? cb.Text : cb.SelectedValue ?? cb.SelectedItem,
|
|
|
+ DateTimePicker dtp => dtp.Checked ? dtp.Value.ToString("yyyy-MM-dd") : null,
|
|
|
+ _ => null
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ private (string Table, string Pk) DbTable() => _table switch
|
|
|
+ {
|
|
|
+ "clients" => ("client", "client_id"),
|
|
|
+ "cars" => ("car", "car_id"),
|
|
|
+ "brands" => ("brand", "brand_id"),
|
|
|
+ "rentals" => ("rental", "rental_id"),
|
|
|
+ _ => throw new InvalidOperationException("Неизвестная таблица.")
|
|
|
+ };
|
|
|
+}
|