619 lines
28 KiB
C#
619 lines
28 KiB
C#
using KronaStaffTerminal.Models;
|
||
using KronaStaffTerminal.Services;
|
||
using Microsoft.UI.Dispatching;
|
||
using Microsoft.UI.Xaml;
|
||
using Microsoft.UI.Xaml.Controls;
|
||
|
||
namespace KronaStaffTerminal;
|
||
|
||
public sealed partial class MainPage : Page
|
||
{
|
||
private readonly TerminalStore _store = new();
|
||
private readonly NotificationService _notifications = new();
|
||
private readonly FingerprintScanner _scanner = new();
|
||
private TerminalState _state = new();
|
||
private bool _loaded;
|
||
private TaskCompletionSource<bool>? _adminAuthCompletion;
|
||
private ContentDialog? _adminAuthDialog;
|
||
private readonly DispatcherQueueTimer _adminSessionTimer;
|
||
private bool _adminSessionActive;
|
||
private bool _adminNavigationInProgress;
|
||
private string? _registrationEmployeeId;
|
||
private DateTimeOffset _adminSessionExpiresAtUtc;
|
||
private static readonly TimeSpan AdminSessionDuration = TimeSpan.FromMinutes(5);
|
||
|
||
public MainPage()
|
||
{
|
||
InitializeComponent();
|
||
_scanner.StatusChanged += message => Enqueue(() => SetStatus(message));
|
||
_scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Регистрация отпечатка: {count}/3"));
|
||
_scanner.RegistrationCompleted += (id, template) => Enqueue(() => _ = CompleteRegistrationAsync(id, template));
|
||
_scanner.FingerRecognized += (id, score) => Enqueue(() => _ = HandleRecognizedFingerAsync(id, score));
|
||
_adminSessionTimer = DispatcherQueue.CreateTimer();
|
||
_adminSessionTimer.Interval = TimeSpan.FromSeconds(15);
|
||
_adminSessionTimer.IsRepeating = true;
|
||
_adminSessionTimer.Tick += (_, _) =>
|
||
{
|
||
if (_adminSessionActive && DateTimeOffset.UtcNow >= _adminSessionExpiresAtUtc)
|
||
{
|
||
LockAdminSession("Сеанс администратора завершён после 5 минут бездействия.");
|
||
}
|
||
};
|
||
_adminSessionTimer.Start();
|
||
}
|
||
|
||
private async void Page_Loaded(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_loaded) return;
|
||
_loaded = true;
|
||
_state = await _store.LoadAsync();
|
||
LoadSettings();
|
||
ApplyStartupSetting();
|
||
RefreshLists();
|
||
ConnectScanner();
|
||
MainTabs.SelectedItem = TerminalTab;
|
||
}
|
||
|
||
private void Page_Unloaded(object sender, RoutedEventArgs e)
|
||
{
|
||
_adminSessionTimer.Stop();
|
||
_scanner.Dispose();
|
||
}
|
||
|
||
private async void MainTabs_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||
{
|
||
if (_adminNavigationInProgress || ReferenceEquals(MainTabs.SelectedItem, TerminalTab)) return;
|
||
if (HasActiveAdminSession)
|
||
{
|
||
TouchAdminSession();
|
||
return;
|
||
}
|
||
|
||
var requestedTab = MainTabs.SelectedItem;
|
||
_adminNavigationInProgress = true;
|
||
MainTabs.SelectedItem = TerminalTab;
|
||
var authorized = await EnsureAdminSessionAsync("вход в административный раздел");
|
||
if (authorized)
|
||
{
|
||
MainTabs.SelectedItem = requestedTab;
|
||
}
|
||
_adminNavigationInProgress = false;
|
||
}
|
||
|
||
private void LogoutAdmin_Click(object sender, RoutedEventArgs e) =>
|
||
LockAdminSession("Административный раздел заблокирован.");
|
||
|
||
public void LockAdminSession() =>
|
||
LockAdminSession("Административный раздел заблокирован при сворачивании.");
|
||
|
||
private void LockAdminSession(string message)
|
||
{
|
||
_adminSessionActive = false;
|
||
_adminSessionExpiresAtUtc = default;
|
||
_adminAuthCompletion?.TrySetResult(false);
|
||
_adminAuthDialog?.Hide();
|
||
if (MainTabs is not null)
|
||
{
|
||
_adminNavigationInProgress = true;
|
||
MainTabs.SelectedItem = TerminalTab;
|
||
_adminNavigationInProgress = false;
|
||
}
|
||
if (_loaded) SetStatus(message, InfoBarSeverity.Informational);
|
||
}
|
||
|
||
private bool HasActiveAdminSession =>
|
||
_adminSessionActive && DateTimeOffset.UtcNow < _adminSessionExpiresAtUtc;
|
||
|
||
private void TouchAdminSession() =>
|
||
_adminSessionExpiresAtUtc = DateTimeOffset.UtcNow.Add(AdminSessionDuration);
|
||
|
||
private async Task<bool> EnsureAdminSessionAsync(string operation)
|
||
{
|
||
if (HasActiveAdminSession)
|
||
{
|
||
TouchAdminSession();
|
||
return true;
|
||
}
|
||
|
||
var authorized = !PinConfigured && !AdministratorFingerprintConfigured
|
||
? await CreateInitialAdminPinAsync()
|
||
: await AuthorizeAdminAsync(operation);
|
||
if (!authorized) return false;
|
||
|
||
_adminSessionActive = true;
|
||
TouchAdminSession();
|
||
SetStatus(AdministratorFingerprintConfigured
|
||
? "Вход администратора выполнен. Сеанс закроется через 5 минут бездействия."
|
||
: "Первичная настройка открыта. Добавьте администратора и зарегистрируйте его отпечаток.",
|
||
InfoBarSeverity.Success);
|
||
return true;
|
||
}
|
||
|
||
private async Task<bool> CreateInitialAdminPinAsync()
|
||
{
|
||
var pinBox = new PasswordBox { Header = "Придумайте PIN администратора", PasswordRevealMode = PasswordRevealMode.Peek };
|
||
var repeatBox = new PasswordBox { Header = "Повторите PIN", PasswordRevealMode = PasswordRevealMode.Peek };
|
||
var content = new StackPanel { Spacing = 12, MinWidth = 320 };
|
||
content.Children.Add(new TextBlock
|
||
{
|
||
Text = "Первичная настройка. После входа добавьте сотрудника-администратора и зарегистрируйте его отпечаток.",
|
||
TextWrapping = TextWrapping.Wrap
|
||
});
|
||
content.Children.Add(pinBox);
|
||
content.Children.Add(repeatBox);
|
||
var dialog = new ContentDialog
|
||
{
|
||
XamlRoot = XamlRoot,
|
||
Title = "Создание администратора",
|
||
Content = content,
|
||
PrimaryButtonText = "Создать PIN",
|
||
CloseButtonText = "Отмена",
|
||
DefaultButton = ContentDialogButton.Primary
|
||
};
|
||
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return false;
|
||
if (pinBox.Password.Length < 6 || pinBox.Password != repeatBox.Password)
|
||
{
|
||
SetStatus("PIN должен содержать не менее 6 символов, оба значения должны совпадать.", InfoBarSeverity.Error);
|
||
return false;
|
||
}
|
||
|
||
var pin = AdminPinService.Hash(pinBox.Password);
|
||
_state.Settings.AdminPinSalt = pin.Salt;
|
||
_state.Settings.AdminPinHash = pin.Hash;
|
||
await _store.SaveAsync(_state);
|
||
return true;
|
||
}
|
||
|
||
private void ConnectScanner()
|
||
{
|
||
try
|
||
{
|
||
_scanner.Connect(_state.Settings.ReaderIndex, _state.Employees
|
||
.Where(employee => employee.HasFingerprint)
|
||
.SelectMany(employee => employee.Fingerprints)
|
||
.Select(fingerprint => (fingerprint.Id, fingerprint.Template)));
|
||
ReaderCombo.Items.Clear();
|
||
for (var index = 0; index < _scanner.DeviceCount; index++)
|
||
{
|
||
ReaderCombo.Items.Add($"SLK20R №{index + 1}");
|
||
}
|
||
ReaderCombo.SelectedIndex = _scanner.ReaderIndex;
|
||
if (_state.Settings.ReaderIndex != _scanner.ReaderIndex)
|
||
{
|
||
_state.Settings.ReaderIndex = _scanner.ReaderIndex;
|
||
_ = _store.SaveAsync(_state);
|
||
}
|
||
if (_scanner.SkippedTemplateCount > 0)
|
||
{
|
||
SetStatus(
|
||
$"Сканер SLK20R №{_scanner.ReaderIndex + 1} подключён. Не удалось загрузить отпечатков: {_scanner.SkippedTemplateCount}. Перерегистрируйте их в разделе сотрудников.",
|
||
InfoBarSeverity.Warning);
|
||
}
|
||
else
|
||
{
|
||
SetStatus($"Сканер SLK20R №{_scanner.ReaderIndex + 1} подключён. Зарегистрировано сотрудников: {_state.Employees.Count(employee => employee.HasFingerprint)}.", InfoBarSeverity.Success);
|
||
}
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
SetStatus(exception.Message, InfoBarSeverity.Error);
|
||
}
|
||
}
|
||
|
||
private async void AddEmployee_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (!await EnsureAdminSessionAsync("добавление сотрудника")) return;
|
||
var name = EmployeeNameBox.Text.Trim();
|
||
var number = PersonnelNumberBox.Text.Trim();
|
||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(number))
|
||
{
|
||
SetStatus("Заполните имя и табельный номер сотрудника.", InfoBarSeverity.Warning);
|
||
return;
|
||
}
|
||
if (_state.Employees.Any(employee => employee.PersonnelNumber.Equals(number, StringComparison.OrdinalIgnoreCase)))
|
||
{
|
||
SetStatus("Сотрудник с таким табельным номером уже есть.", InfoBarSeverity.Warning);
|
||
return;
|
||
}
|
||
if (AdministratorCheckBox.IsChecked == true && SecurityConfigured &&
|
||
!await AuthorizeAdminAsync("назначение нового администратора"))
|
||
{
|
||
return;
|
||
}
|
||
|
||
var employee = new EmployeeRecord
|
||
{
|
||
DisplayName = name,
|
||
PersonnelNumber = number,
|
||
Position = PositionBox.Text.Trim(),
|
||
Gender = GenderComboBox.SelectedIndex switch
|
||
{
|
||
1 => "Мужской",
|
||
2 => "Женский",
|
||
_ => ""
|
||
},
|
||
IsAdministrator = AdministratorCheckBox.IsChecked == true
|
||
};
|
||
_state.Employees.Add(employee);
|
||
await _store.SaveAsync(_state);
|
||
EmployeeNameBox.Text = "";
|
||
PersonnelNumberBox.Text = "";
|
||
PositionBox.Text = "";
|
||
GenderComboBox.SelectedIndex = 0;
|
||
AdministratorCheckBox.IsChecked = false;
|
||
RefreshLists();
|
||
EmployeesList.SelectedItem = employee;
|
||
SetStatus($"{name} добавлен. Теперь зарегистрируйте отпечаток.", InfoBarSeverity.Success);
|
||
}
|
||
|
||
private async void RegisterFingerprint_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (!await EnsureAdminSessionAsync("регистрация отпечатка")) return;
|
||
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
|
||
{
|
||
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
|
||
return;
|
||
}
|
||
if (!_scanner.IsConnected)
|
||
{
|
||
SetStatus("Сканер не подключён.", InfoBarSeverity.Error);
|
||
return;
|
||
}
|
||
var fingerprintId = _state.Employees
|
||
.SelectMany(item => item.Fingerprints)
|
||
.Select(item => item.Id)
|
||
.DefaultIfEmpty(0)
|
||
.Max() + 1;
|
||
_registrationEmployeeId = employee.Id;
|
||
_scanner.BeginRegistration(fingerprintId);
|
||
}
|
||
|
||
private async Task CompleteRegistrationAsync(int fingerprintId, string template)
|
||
{
|
||
var employee = _state.Employees.FirstOrDefault(item => item.Id == _registrationEmployeeId);
|
||
if (employee is null) return;
|
||
employee.Fingerprints.Add(new FingerprintRecord
|
||
{
|
||
Id = fingerprintId,
|
||
Template = template,
|
||
Label = $"Отпечаток {employee.Fingerprints.Count + 1}"
|
||
});
|
||
_registrationEmployeeId = null;
|
||
await _store.SaveAsync(_state);
|
||
RefreshLists();
|
||
TerminalMessage.Text = $"Новый отпечаток сотрудника «{employee.DisplayName}» зарегистрирован";
|
||
SetStatus($"Добавлен отпечаток №{employee.Fingerprints.Count} сотрудника «{employee.DisplayName}».", InfoBarSeverity.Success);
|
||
}
|
||
|
||
private async void DeleteFingerprints_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (!await EnsureAdminSessionAsync("удаление отпечатков")) return;
|
||
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
|
||
{
|
||
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
|
||
return;
|
||
}
|
||
if (!employee.HasFingerprint)
|
||
{
|
||
SetStatus("У выбранного сотрудника нет зарегистрированных отпечатков.", InfoBarSeverity.Informational);
|
||
return;
|
||
}
|
||
|
||
var dialog = new ContentDialog
|
||
{
|
||
XamlRoot = XamlRoot,
|
||
Title = "Удалить все отпечатки сотрудника?",
|
||
Content = $"{employee.DisplayName}: будет удалено отпечатков — {employee.Fingerprints.Count}. Сам сотрудник останется.",
|
||
PrimaryButtonText = "Удалить отпечатки",
|
||
CloseButtonText = "Отмена",
|
||
DefaultButton = ContentDialogButton.Close
|
||
};
|
||
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
|
||
if (!await AuthorizeAdminAsync("удаление отпечатков сотрудника")) return;
|
||
|
||
employee.Fingerprints.Clear();
|
||
await _store.SaveAsync(_state);
|
||
RefreshLists();
|
||
ConnectScanner();
|
||
SetStatus($"Все отпечатки сотрудника «{employee.DisplayName}» удалены.", InfoBarSeverity.Success);
|
||
}
|
||
|
||
private async void ToggleAdministrator_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (!await EnsureAdminSessionAsync("изменение прав администратора")) return;
|
||
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
|
||
{
|
||
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
|
||
return;
|
||
}
|
||
if (SecurityConfigured && !await AuthorizeAdminAsync("изменение прав администратора")) return;
|
||
if (employee.IsAdministrator && _state.Employees.Count(item => item.IsAdministrator && item.HasFingerprint) <= 1)
|
||
{
|
||
SetStatus("Нельзя снять права с единственного администратора с зарегистрированным отпечатком.", InfoBarSeverity.Warning);
|
||
return;
|
||
}
|
||
employee.IsAdministrator = !employee.IsAdministrator;
|
||
await _store.SaveAsync(_state);
|
||
RefreshLists();
|
||
SetStatus(employee.IsAdministrator ? $"{employee.DisplayName} назначен администратором." : $"Права администратора у {employee.DisplayName} сняты.", InfoBarSeverity.Success);
|
||
}
|
||
|
||
private async void DeleteEmployee_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (!await EnsureAdminSessionAsync("удаление сотрудника")) return;
|
||
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
|
||
{
|
||
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
|
||
return;
|
||
}
|
||
var dialog = new ContentDialog
|
||
{
|
||
XamlRoot = XamlRoot,
|
||
Title = "Удалить сотрудника и отпечаток?",
|
||
Content = $"{employee.DisplayName}, его отпечаток и право доступа будут удалены из терминала.",
|
||
PrimaryButtonText = "Продолжить",
|
||
CloseButtonText = "Отмена",
|
||
DefaultButton = ContentDialogButton.Close
|
||
};
|
||
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
|
||
if (!SecurityConfigured)
|
||
{
|
||
SetStatus("Сначала настройте PIN или отпечаток администратора.", InfoBarSeverity.Warning);
|
||
return;
|
||
}
|
||
if (!await AuthorizeAdminAsync("удаление сотрудника и отпечатка")) return;
|
||
if (employee.IsAdministrator && _state.Employees.Count(item => item.IsAdministrator && item.HasFingerprint) <= 1)
|
||
{
|
||
SetStatus("Нельзя удалить единственного администратора.", InfoBarSeverity.Warning);
|
||
return;
|
||
}
|
||
|
||
_state.Employees.Remove(employee);
|
||
await _store.SaveAsync(_state);
|
||
RefreshLists();
|
||
ConnectScanner();
|
||
SetStatus("Сотрудник и его отпечаток удалены.", InfoBarSeverity.Success);
|
||
}
|
||
|
||
private async Task HandleRecognizedFingerAsync(int fingerprintId, int score)
|
||
{
|
||
var employee = _state.Employees.FirstOrDefault(item =>
|
||
item.Fingerprints.Any(fingerprint => fingerprint.Id == fingerprintId));
|
||
if (employee is null) return;
|
||
if (_adminAuthCompletion is not null)
|
||
{
|
||
if (employee.IsAdministrator)
|
||
{
|
||
TerminalMessage.Text = $"Администратор {employee.DisplayName} подтверждён";
|
||
_adminAuthCompletion.TrySetResult(true);
|
||
_adminAuthDialog?.Hide();
|
||
}
|
||
else
|
||
{
|
||
SetStatus($"{employee.DisplayName} не имеет прав администратора.", InfoBarSeverity.Warning);
|
||
}
|
||
return;
|
||
}
|
||
await RecordAttendanceAsync(employee, score);
|
||
}
|
||
|
||
private async Task RecordAttendanceAsync(EmployeeRecord employee, int score)
|
||
{
|
||
var now = DateTimeOffset.UtcNow;
|
||
var last = _state.Attendance
|
||
.Where(item => item.EmployeeId == employee.Id)
|
||
.OrderByDescending(item => item.OccurredAtUtc)
|
||
.FirstOrDefault();
|
||
var guard = TimeSpan.FromSeconds(Math.Clamp(_state.Settings.DuplicateScanGuardSeconds, 10, 3600));
|
||
if (last is not null && now - last.OccurredAtUtc < guard)
|
||
{
|
||
var remaining = Math.Max(1, (int)Math.Ceiling((guard - (now - last.OccurredAtUtc)).TotalSeconds));
|
||
TerminalMessage.Text = $"{employee.DisplayName}\nПовторный скан отклонён\nПовторите через {remaining} сек.";
|
||
SetStatus($"Повторный скан защищён: событие не создано. Последнее событие — «{last.EventType}».", InfoBarSeverity.Informational);
|
||
return;
|
||
}
|
||
|
||
var isNewDay = last is null || last.OccurredAtUtc.ToLocalTime().Date != now.ToLocalTime().Date;
|
||
var eventType = isNewDay || last!.EventType == "Уход" ? "Приход" : "Уход";
|
||
var record = new AttendanceRecord
|
||
{
|
||
EmployeeId = employee.Id,
|
||
EmployeeName = employee.DisplayName,
|
||
OccurredAtUtc = now,
|
||
ReaderIndex = _scanner.ReaderIndex,
|
||
EventType = eventType
|
||
};
|
||
_state.Attendance.Insert(0, record);
|
||
await _store.SaveAsync(_state);
|
||
RefreshLists();
|
||
TerminalMessage.Text = $"{employee.DisplayName}\n{eventType} отмечен в {now.ToLocalTime():HH:mm}";
|
||
SetStatus($"{eventType} отмечен. Точность распознавания: {score}.", InfoBarSeverity.Success);
|
||
|
||
var delivery = await _notifications.SendAttendanceAsync(_state.Settings, employee.DisplayName, eventType, now.ToLocalTime());
|
||
record.NotificationDelivered = delivery.Delivered;
|
||
record.NotificationError = delivery.Error;
|
||
await _store.SaveAsync(_state);
|
||
RefreshLists();
|
||
if (delivery.Error is not null)
|
||
{
|
||
SetStatus($"{eventType} сохранён, но уведомление не отправлено: {delivery.Error}", InfoBarSeverity.Warning);
|
||
}
|
||
}
|
||
|
||
private async void ClearAttendance_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (!await EnsureAdminSessionAsync("очистка журнала")) return;
|
||
var dialog = new ContentDialog
|
||
{
|
||
XamlRoot = XamlRoot,
|
||
Title = "Очистить журнал отметок?",
|
||
Content = $"Будут удалены все записи прихода и ухода: {_state.Attendance.Count}. Сотрудники и отпечатки останутся.",
|
||
PrimaryButtonText = "Очистить журнал",
|
||
CloseButtonText = "Отмена",
|
||
DefaultButton = ContentDialogButton.Close
|
||
};
|
||
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
|
||
if (!await AuthorizeAdminAsync("очистка журнала прихода и ухода")) return;
|
||
|
||
_state.Attendance.Clear();
|
||
await _store.SaveAsync(_state);
|
||
RefreshLists();
|
||
TerminalMessage.Text = "Журнал отметок очищен";
|
||
SetStatus("Журнал прихода и ухода очищен.", InfoBarSeverity.Success);
|
||
}
|
||
|
||
public async Task<bool> AuthorizeExitAsync()
|
||
{
|
||
if (!SecurityConfigured)
|
||
{
|
||
SetStatus("Выход заблокирован: сначала настройте PIN или отпечаток администратора.", InfoBarSeverity.Warning);
|
||
return false;
|
||
}
|
||
return await AuthorizeAdminAsync("полное завершение терминала");
|
||
}
|
||
|
||
private bool PinConfigured =>
|
||
!string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash);
|
||
|
||
private bool AdministratorFingerprintConfigured =>
|
||
_state.Employees.Any(employee => employee.IsAdministrator && employee.HasFingerprint);
|
||
|
||
private bool SecurityConfigured =>
|
||
PinConfigured || AdministratorFingerprintConfigured;
|
||
|
||
private async Task<bool> AuthorizeAdminAsync(string operation)
|
||
{
|
||
var hasAdministratorFingerprint = AdministratorFingerprintConfigured;
|
||
var pinBox = new PasswordBox
|
||
{
|
||
Header = "PIN администратора",
|
||
PasswordRevealMode = PasswordRevealMode.Peek,
|
||
MinWidth = 280
|
||
};
|
||
var content = new StackPanel { Spacing = 12, MinWidth = 320 };
|
||
content.Children.Add(new TextBlock
|
||
{
|
||
Text = hasAdministratorFingerprint
|
||
? "Введите PIN или приложите зарегистрированный палец администратора."
|
||
: "Введите PIN. После входа зарегистрируйте отпечаток администратора.",
|
||
TextWrapping = TextWrapping.Wrap
|
||
});
|
||
content.Children.Add(pinBox);
|
||
|
||
_adminAuthCompletion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||
_adminAuthDialog = new ContentDialog
|
||
{
|
||
XamlRoot = XamlRoot,
|
||
Title = $"Вход администратора: {operation}",
|
||
Content = content,
|
||
PrimaryButtonText = "Войти по PIN",
|
||
CloseButtonText = "Отмена",
|
||
DefaultButton = ContentDialogButton.Primary
|
||
};
|
||
var dialogResult = await _adminAuthDialog.ShowAsync();
|
||
var fingerprintAuthorized = _adminAuthCompletion.Task.IsCompletedSuccessfully &&
|
||
_adminAuthCompletion.Task.Result;
|
||
_adminAuthCompletion.TrySetResult(false);
|
||
var authorized = fingerprintAuthorized ||
|
||
(dialogResult == ContentDialogResult.Primary &&
|
||
AdminPinService.Verify(pinBox.Password, _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash));
|
||
_adminAuthCompletion = null;
|
||
_adminAuthDialog = null;
|
||
SetStatus(authorized
|
||
? fingerprintAuthorized ? "Вход выполнен по отпечатку администратора." : "Вход выполнен по PIN администратора."
|
||
: dialogResult == ContentDialogResult.Primary ? "Неверный PIN администратора." : "Вход отменён.",
|
||
authorized ? InfoBarSeverity.Success : dialogResult == ContentDialogResult.Primary ? InfoBarSeverity.Error : InfoBarSeverity.Warning);
|
||
return authorized;
|
||
}
|
||
|
||
private async void SetAdminPin_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (!await EnsureAdminSessionAsync("изменение PIN")) return;
|
||
var newPin = NewAdminPinBox.Password.Trim();
|
||
if (newPin.Length < 6)
|
||
{
|
||
SetStatus("PIN должен содержать не менее 6 символов.", InfoBarSeverity.Warning);
|
||
return;
|
||
}
|
||
if (!string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash) && !await AuthorizeAdminAsync("изменение PIN")) return;
|
||
var pin = AdminPinService.Hash(newPin);
|
||
_state.Settings.AdminPinSalt = pin.Salt;
|
||
_state.Settings.AdminPinHash = pin.Hash;
|
||
NewAdminPinBox.Password = "";
|
||
await _store.SaveAsync(_state);
|
||
SetStatus("Новый PIN администратора сохранён.", InfoBarSeverity.Success);
|
||
}
|
||
|
||
private async void SaveSettings_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (!await EnsureAdminSessionAsync("изменение настроек")) return;
|
||
CopySettingsFromControls();
|
||
await _store.SaveAsync(_state);
|
||
ApplyStartupSetting();
|
||
ConnectScanner();
|
||
SetStatus("Настройки сохранены.", InfoBarSeverity.Success);
|
||
}
|
||
|
||
private async void TestNotification_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (!await EnsureAdminSessionAsync("проверка уведомлений")) return;
|
||
CopySettingsFromControls();
|
||
var result = await _notifications.SendTestAsync(_state.Settings);
|
||
SetStatus(result.Delivered ? "Тестовое сообщение отправлено." : result.Error ?? "Включите Telegram или MAX и заполните реквизиты.", result.Delivered ? InfoBarSeverity.Success : InfoBarSeverity.Warning);
|
||
}
|
||
|
||
private void CopySettingsFromControls()
|
||
{
|
||
_state.Settings.ReaderIndex = Math.Max(0, ReaderCombo.SelectedIndex);
|
||
_state.Settings.StartWithWindows = StartWithWindowsSwitch.IsOn;
|
||
_state.Settings.DuplicateScanGuardSeconds = double.IsNaN(DuplicateGuardBox.Value) ? 120 : Math.Clamp((int)DuplicateGuardBox.Value, 10, 3600);
|
||
_state.Settings.TelegramEnabled = TelegramEnabledSwitch.IsOn;
|
||
_state.Settings.TelegramBotToken = TelegramTokenBox.Password.Trim();
|
||
_state.Settings.TelegramChatId = TelegramChatBox.Text.Trim();
|
||
_state.Settings.MaxEnabled = MaxEnabledSwitch.IsOn;
|
||
_state.Settings.MaxBotToken = MaxTokenBox.Password.Trim();
|
||
_state.Settings.MaxChatId = MaxChatBox.Text.Trim();
|
||
}
|
||
|
||
private void LoadSettings()
|
||
{
|
||
StartWithWindowsSwitch.IsOn = _state.Settings.StartWithWindows;
|
||
DuplicateGuardBox.Value = Math.Clamp(_state.Settings.DuplicateScanGuardSeconds, 10, 3600);
|
||
TelegramEnabledSwitch.IsOn = _state.Settings.TelegramEnabled;
|
||
TelegramTokenBox.Password = _state.Settings.TelegramBotToken;
|
||
TelegramChatBox.Text = _state.Settings.TelegramChatId;
|
||
MaxEnabledSwitch.IsOn = _state.Settings.MaxEnabled;
|
||
MaxTokenBox.Password = _state.Settings.MaxBotToken;
|
||
MaxChatBox.Text = _state.Settings.MaxChatId;
|
||
}
|
||
|
||
private void ApplyStartupSetting()
|
||
{
|
||
try
|
||
{
|
||
StartupService.SetEnabled(_state.Settings.StartWithWindows);
|
||
StartWithWindowsSwitch.IsOn = StartupService.IsEnabled();
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
SetStatus($"Не удалось изменить автозагрузку: {exception.Message}", InfoBarSeverity.Warning);
|
||
}
|
||
}
|
||
|
||
private void RefreshLists()
|
||
{
|
||
EmployeesList.ItemsSource = null;
|
||
EmployeesList.ItemsSource = _state.Employees.OrderBy(employee => employee.DisplayName).ToList();
|
||
AttendanceList.ItemsSource = null;
|
||
AttendanceList.ItemsSource = _state.Attendance.OrderByDescending(item => item.OccurredAtUtc).Take(100).ToList();
|
||
}
|
||
|
||
private void SetStatus(string message, InfoBarSeverity severity = InfoBarSeverity.Informational)
|
||
{
|
||
StatusBar.Message = message;
|
||
StatusBar.Severity = severity;
|
||
StatusBar.IsOpen = true;
|
||
}
|
||
|
||
private static void Enqueue(Action action) => App.DispatcherQueue.TryEnqueue(() => action());
|
||
}
|