1751 lines
76 KiB
C#
1751 lines
76 KiB
C#
using KronaStaffTerminal.Models;
|
||
using KronaStaffTerminal.Services;
|
||
using Microsoft.UI.Dispatching;
|
||
using Microsoft.UI.Xaml;
|
||
using Microsoft.UI.Xaml.Controls;
|
||
using Microsoft.UI.Xaml.Input;
|
||
using System.Text;
|
||
using Windows.Storage;
|
||
using Windows.Storage.Pickers;
|
||
|
||
namespace KronaStaffTerminal;
|
||
|
||
public sealed partial class MainPage : Page
|
||
{
|
||
private readonly TerminalStore _store = new();
|
||
private readonly NotificationService _notifications = new();
|
||
private readonly AttendanceSyncService _attendanceSync = new();
|
||
private readonly AttendanceTracker _attendanceTracker = new();
|
||
private readonly TerminalIpcClient _ipcClient = new();
|
||
private readonly IFingerprintScanner _scanner;
|
||
private readonly IUpdateSource _updateSource = new ForgejoUpdateSource();
|
||
|
||
/// <summary>Инъекция фабрики для тестирования. По умолчанию создаёт реальный FingerprintScanner.</summary>
|
||
public static Func<IFingerprintScanner>? ScannerFactory { get; set; }
|
||
|
||
private TerminalState _state = new();
|
||
private bool _loaded;
|
||
private TaskCompletionSource<bool>? _adminAuthCompletion;
|
||
private ContentDialog? _adminAuthDialog;
|
||
private readonly DispatcherQueueTimer _adminSessionTimer;
|
||
private readonly DispatcherQueueTimer _updateTimer;
|
||
private readonly DispatcherQueueTimer _automaticCheckoutTimer;
|
||
private readonly CancellationTokenSource _updateCancellation = new();
|
||
private AvailableUpdate? _availableUpdate;
|
||
private bool _updateCheckInProgress;
|
||
private bool _automaticCheckoutInProgress;
|
||
private bool _attendanceSyncInProgress;
|
||
private bool _adminSessionActive;
|
||
private bool _adminNavigationInProgress;
|
||
private bool _stateLoadFailed;
|
||
private bool _scannerReconnectPending;
|
||
private bool _refreshingReaders;
|
||
private int? _attendanceSuppressedFingerprintId;
|
||
private DateTimeOffset _attendanceSuppressedUntilUtc;
|
||
private string? _registrationEmployeeId;
|
||
private string _registrationFingerLabel = "Отпечаток";
|
||
private bool _refreshingLists;
|
||
private bool _applicationLocked = true;
|
||
private string _lastAdminActor = "unknown";
|
||
private DateTimeOffset _adminSessionExpiresAtUtc;
|
||
private static readonly TimeSpan AdminSessionDuration = TimeSpan.FromMinutes(5);
|
||
|
||
public MainPage()
|
||
{
|
||
try
|
||
{
|
||
InitializeComponent();
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
CrashLogService.Write(exception);
|
||
throw;
|
||
}
|
||
|
||
_scanner = ScannerFactory?.Invoke() ?? new FingerprintScanner();
|
||
_scanner.StatusChanged += message => Enqueue(() => SetStatus(message));
|
||
_scanner.StateChanged += (state, message) => Enqueue(() => HandleScannerStateChanged(state, message));
|
||
_scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Мастер регистрации: принят скан {count}/3. Уберите палец и приложите снова."));
|
||
_scanner.RegistrationCompleted += (id, template) => EnqueueTask(() => CompleteRegistrationAsync(id, template));
|
||
_scanner.FingerRecognized += (id, score) => EnqueueTask(() => HandleRecognizedFingerAsync(id, score));
|
||
_scanner.ConnectionLost += () => EnqueueTask(ReconnectScannerAsync);
|
||
_scanner.RegistrationCancelled += () => Enqueue(() => _registrationEmployeeId = null);
|
||
_adminSessionTimer = DispatcherQueue.CreateTimer();
|
||
_adminSessionTimer.Interval = TimeSpan.FromSeconds(15);
|
||
_adminSessionTimer.IsRepeating = true;
|
||
_adminSessionTimer.Tick += (_, _) =>
|
||
{
|
||
if (_adminSessionActive && DateTimeOffset.UtcNow >= _adminSessionExpiresAtUtc)
|
||
{
|
||
LockAdminSession("Сеанс администратора завершён после 5 минут бездействия.");
|
||
}
|
||
};
|
||
_adminSessionTimer.Start();
|
||
_updateTimer = DispatcherQueue.CreateTimer();
|
||
_updateTimer.Interval = TimeSpan.FromHours(6);
|
||
_updateTimer.IsRepeating = true;
|
||
_updateTimer.Tick += (_, _) => EnqueueTask(() => CheckForUpdatesAsync(false));
|
||
_automaticCheckoutTimer = DispatcherQueue.CreateTimer();
|
||
_automaticCheckoutTimer.Interval = TimeSpan.FromMinutes(1);
|
||
_automaticCheckoutTimer.IsRepeating = true;
|
||
_automaticCheckoutTimer.Tick += (_, _) => EnqueueTask(async () =>
|
||
{
|
||
await RunAutomaticCheckoutIfDueAsync();
|
||
await SyncPendingAttendanceAsync();
|
||
});
|
||
}
|
||
|
||
private async void Page_Loaded(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_loaded) return;
|
||
_loaded = true;
|
||
try
|
||
{
|
||
_state = await _store.LoadAsync();
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
_stateLoadFailed = true;
|
||
CrashLogService.Write(exception);
|
||
MainTabs.SelectedItem = TerminalTab;
|
||
TerminalMessage.Text = "Хранилище данных не открыто\nОбратитесь к администратору";
|
||
SetStatus(exception.Message, InfoBarSeverity.Error);
|
||
return;
|
||
}
|
||
LoadSettings();
|
||
ApplyStartupSetting();
|
||
RefreshLists();
|
||
ConnectScanner();
|
||
_updateTimer.Start();
|
||
_automaticCheckoutTimer.Start();
|
||
MainTabs.SelectedItem = TerminalTab;
|
||
LockScreenMessage.Text = SecurityConfigured
|
||
? "Введите PIN администратора или приложите зарегистрированный палец."
|
||
: "Первый запуск: нажмите «Войти», чтобы создать PIN администратора.";
|
||
if (_store.RecoveryNotice is not null)
|
||
{
|
||
SetStatus(_store.RecoveryNotice, InfoBarSeverity.Warning);
|
||
}
|
||
await RunAutomaticCheckoutIfDueAsync();
|
||
await SyncPendingAttendanceAsync();
|
||
await CheckForUpdatesAsync(false);
|
||
}
|
||
|
||
private void Page_Unloaded(object sender, RoutedEventArgs e)
|
||
{
|
||
_loaded = false;
|
||
_adminSessionTimer.Stop();
|
||
_updateTimer.Stop();
|
||
_automaticCheckoutTimer.Stop();
|
||
_updateCancellation.Cancel();
|
||
_scanner.Dispose();
|
||
}
|
||
|
||
private async Task ReconnectScannerAsync()
|
||
{
|
||
if (_scannerReconnectPending || !_loaded) return;
|
||
_scannerReconnectPending = true;
|
||
try
|
||
{
|
||
_scanner.Disconnect();
|
||
while (_loaded && !_scanner.IsConnected)
|
||
{
|
||
await Task.Delay(TimeSpan.FromSeconds(3));
|
||
if (!_loaded) return;
|
||
ConnectScanner();
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
_scannerReconnectPending = false;
|
||
}
|
||
}
|
||
|
||
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) =>
|
||
LockApplication("Программа заблокирована.");
|
||
|
||
public void LockAdminSession() =>
|
||
LockApplication("Программа заблокирована при сворачивании.");
|
||
|
||
private void LockApplication(string message)
|
||
{
|
||
LockAdminSession(message);
|
||
_applicationLocked = true;
|
||
if (ProtectedContent is not null) ProtectedContent.Visibility = Visibility.Collapsed;
|
||
if (ApplicationLockScreen is not null) ApplicationLockScreen.Visibility = Visibility.Visible;
|
||
if (UnlockPinBox is not null) UnlockPinBox.Password = "";
|
||
if (LockScreenMessage is not null) LockScreenMessage.Text = message + " Введите PIN или приложите палец администратора.";
|
||
}
|
||
|
||
private void UnlockApplication(string actor, string message)
|
||
{
|
||
_applicationLocked = false;
|
||
_lastAdminActor = actor;
|
||
_adminSessionActive = true;
|
||
TouchAdminSession();
|
||
UnlockPinBox.Password = "";
|
||
ApplicationLockScreen.Visibility = Visibility.Collapsed;
|
||
ProtectedContent.Visibility = Visibility.Visible;
|
||
MainTabs.SelectedItem = TerminalTab;
|
||
SetStatus(message, InfoBarSeverity.Success);
|
||
}
|
||
|
||
private async void UnlockApplication_Click(object sender, RoutedEventArgs e) =>
|
||
await UnlockApplicationByPinAsync();
|
||
|
||
private async void UnlockPinBox_KeyDown(object sender, KeyRoutedEventArgs e)
|
||
{
|
||
if (e.Key != Windows.System.VirtualKey.Enter) return;
|
||
e.Handled = true;
|
||
await UnlockApplicationByPinAsync();
|
||
}
|
||
|
||
private async Task UnlockApplicationByPinAsync()
|
||
{
|
||
if (_stateLoadFailed)
|
||
{
|
||
LockScreenMessage.Text = "Хранилище не открыто. Доступ заблокирован.";
|
||
return;
|
||
}
|
||
if (!SecurityConfigured)
|
||
{
|
||
if (await EnsureAdminSessionAsync("первичная настройка"))
|
||
{
|
||
UnlockApplication("initial-pin", "Первичная настройка выполнена.");
|
||
}
|
||
return;
|
||
}
|
||
|
||
var remainingLockout = _state.Settings.AdminPinBlockedUntilUtc - DateTimeOffset.UtcNow;
|
||
if (remainingLockout > TimeSpan.Zero)
|
||
{
|
||
LockScreenMessage.Text = $"PIN временно заблокирован. Повторите через {Math.Ceiling(remainingLockout.TotalSeconds)} сек. Можно приложить палец администратора.";
|
||
return;
|
||
}
|
||
|
||
var enteredPin = UnlockPinBox.Password;
|
||
var authorized = AdminPinService.VerifyAndRecord(
|
||
enteredPin,
|
||
_state.Settings,
|
||
DateTimeOffset.UtcNow,
|
||
out var errorMessage,
|
||
out var rehashNeeded);
|
||
|
||
if (!authorized && !string.Equals(enteredPin, enteredPin.Trim(), StringComparison.Ordinal))
|
||
{
|
||
authorized = AdminPinService.VerifyAndRecord(
|
||
enteredPin.Trim(),
|
||
_state.Settings,
|
||
DateTimeOffset.UtcNow,
|
||
out errorMessage,
|
||
out rehashNeeded);
|
||
}
|
||
|
||
await _store.SaveAsync(_state);
|
||
UnlockPinBox.Password = "";
|
||
|
||
if (authorized)
|
||
{
|
||
UnlockApplication("local-pin", "Вход выполнен по PIN администратора.");
|
||
return;
|
||
}
|
||
|
||
LockScreenMessage.Text = errorMessage ?? "Неверный PIN.";
|
||
}
|
||
|
||
private void LockAdminSession(string message)
|
||
{
|
||
CancelRegistration();
|
||
_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 (_stateLoadFailed)
|
||
{
|
||
SetStatus("Административные действия заблокированы: хранилище данных не открыто.", InfoBarSeverity.Error);
|
||
return false;
|
||
}
|
||
if (HasActiveAdminSession)
|
||
{
|
||
TouchAdminSession();
|
||
return true;
|
||
}
|
||
|
||
var authorized = !PinConfigured && !AdministratorFingerprintConfigured
|
||
? await CreateInitialAdminPinAsync()
|
||
: await AuthorizeAdminAsync(operation);
|
||
if (!authorized) return false;
|
||
|
||
_adminSessionActive = true;
|
||
TouchAdminSession();
|
||
SetStatus(AdministratorEmployeeConfigured
|
||
? "Вход администратора выполнен. Сеанс закроется через 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 = "Первичная настройка. PIN работает без сканера. После входа добавьте первого сотрудника-администратора; отпечаток можно зарегистрировать позже.",
|
||
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;
|
||
}
|
||
|
||
// WinUI controls are apartment-bound. Read the value on the UI thread
|
||
// before moving the expensive password hashing to the thread pool.
|
||
var enteredPin = pinBox.Password;
|
||
var pin = await Task.Run(() => AdminPinService.Hash(enteredPin));
|
||
_state.Settings.AdminPinSalt = pin.Salt;
|
||
_state.Settings.AdminPinHash = pin.Hash;
|
||
_state.Settings.AdminPinIterations = pin.Iterations;
|
||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||
_lastAdminActor = "initial-pin";
|
||
return true;
|
||
}
|
||
|
||
private void HandleScannerStateChanged(ScannerState state, string message)
|
||
{
|
||
SetStatus(message, state switch
|
||
{
|
||
ScannerState.Connected => InfoBarSeverity.Success,
|
||
ScannerState.Busy => InfoBarSeverity.Informational,
|
||
ScannerState.DriverError => InfoBarSeverity.Error,
|
||
_ => InfoBarSeverity.Warning
|
||
});
|
||
|
||
var pendingCount = _state?.Attendance?.Count(a => !a.ServerSynced) ?? 0;
|
||
var trayState = state == ScannerState.Connected
|
||
? (_state?.Attendance?.Any(a => !a.ServerSynced && a.ServerSyncError != null) == true ? TrayState.NoServer : TrayState.Normal)
|
||
: TrayState.NoScanner;
|
||
|
||
((App)Application.Current).UpdateTray(trayState, pendingCount, message);
|
||
}
|
||
|
||
private void ConnectScanner()
|
||
{
|
||
try
|
||
{
|
||
_scanner.Connect(_state.Settings.ReaderIndex, _state.Employees
|
||
.Where(employee => employee.HasFingerprint && !employee.IsBlocked)
|
||
.SelectMany(employee => employee.Fingerprints)
|
||
.Select(fingerprint => (fingerprint.Id, fingerprint.Template)));
|
||
_refreshingReaders = true;
|
||
try
|
||
{
|
||
ReaderCombo.Items.Clear();
|
||
foreach (var device in _scanner.DiscoveredDevices)
|
||
{
|
||
ReaderCombo.Items.Add(device.DisplayName);
|
||
}
|
||
var selectedIdx = _scanner.DiscoveredDevices.ToList().FindIndex(d => d.Index == _scanner.ReaderIndex);
|
||
ReaderCombo.SelectedIndex = selectedIdx >= 0 ? selectedIdx : 0;
|
||
}
|
||
finally
|
||
{
|
||
_refreshingReaders = false;
|
||
}
|
||
if (_state.Settings.ReaderIndex != _scanner.ReaderIndex)
|
||
{
|
||
_state.Settings.ReaderIndex = _scanner.ReaderIndex;
|
||
SaveStateInBackground();
|
||
}
|
||
|
||
var activeDevice = _scanner.DiscoveredDevices.FirstOrDefault(d => d.Index == _scanner.ReaderIndex);
|
||
var deviceName = activeDevice?.DisplayName ?? $"SLK20R №{_scanner.ReaderIndex + 1}";
|
||
|
||
if (_scanner.SkippedTemplateCount > 0)
|
||
{
|
||
SetStatus(
|
||
$"Сканер {deviceName} подключён. Не удалось загрузить отпечатков: {_scanner.SkippedTemplateCount}. Перерегистрируйте их в разделе сотрудников.",
|
||
InfoBarSeverity.Warning);
|
||
}
|
||
else
|
||
{
|
||
SetStatus($"Сканер {deviceName} подключён. Зарегистрировано сотрудников: {_state.Employees.Count(employee => employee.HasFingerprint)}.", InfoBarSeverity.Success);
|
||
}
|
||
|
||
var pendingCount = _state.Attendance.Count(a => !a.ServerSynced);
|
||
((App)Application.Current).UpdateTray(TrayState.Normal, pendingCount, $"Сканер {deviceName} подключён");
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
_refreshingReaders = true;
|
||
try
|
||
{
|
||
ReaderCombo.Items.Clear();
|
||
ReaderCombo.SelectedIndex = -1;
|
||
}
|
||
finally
|
||
{
|
||
_refreshingReaders = false;
|
||
}
|
||
SetStatus($"{exception.Message} Проверьте USB-подключение и нажмите «Найти сканеры».", InfoBarSeverity.Error);
|
||
var pendingCount = _state?.Attendance?.Count(a => !a.ServerSynced) ?? 0;
|
||
((App)Application.Current).UpdateTray(TrayState.NoScanner, pendingCount, "Сканер не подключён");
|
||
}
|
||
}
|
||
|
||
private void ScanReaders_Click(object sender, RoutedEventArgs e) => ConnectScanner();
|
||
|
||
private void ReaderCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||
{
|
||
if (_refreshingReaders || !_loaded || ReaderCombo.SelectedIndex < 0) return;
|
||
if (_scanner.IsConnected && ReaderCombo.SelectedIndex == _scanner.ReaderIndex) return;
|
||
|
||
_state.Settings.ReaderIndex = ReaderCombo.SelectedIndex;
|
||
ConnectScanner();
|
||
}
|
||
|
||
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 && AdministratorEmployeeConfigured &&
|
||
!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, createRecoveryPoint: true);
|
||
EmployeeNameBox.Text = "";
|
||
PersonnelNumberBox.Text = "";
|
||
PositionBox.Text = "";
|
||
GenderComboBox.SelectedIndex = 0;
|
||
AdministratorCheckBox.IsChecked = false;
|
||
RefreshLists();
|
||
EmployeesList.SelectedItem = employee;
|
||
SetStatus(employee.IsAdministrator
|
||
? $"{name} добавлен как администратор. Отпечаток можно зарегистрировать после подключения сканера."
|
||
: $"{name} добавлен. Отпечаток можно зарегистрировать позже.",
|
||
InfoBarSeverity.Success);
|
||
}
|
||
|
||
private async void EditEmployee_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (!await EnsureAdminSessionAsync("редактирование сотрудника")) return;
|
||
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
|
||
{
|
||
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
|
||
return;
|
||
}
|
||
|
||
var personnelNumberBox = new TextBox
|
||
{
|
||
Header = "Табельный номер",
|
||
Text = employee.PersonnelNumber,
|
||
MinWidth = 360
|
||
};
|
||
var nameBox = new TextBox
|
||
{
|
||
Header = "Имя сотрудника",
|
||
Text = employee.DisplayName,
|
||
MinWidth = 360
|
||
};
|
||
var positionBox = new TextBox
|
||
{
|
||
Header = "Должность",
|
||
Text = employee.Position,
|
||
MinWidth = 360
|
||
};
|
||
var genderBox = new ComboBox
|
||
{
|
||
Header = "Пол",
|
||
MinWidth = 360,
|
||
ItemsSource = new[] { "Не указан", "Мужской", "Женский" },
|
||
SelectedIndex = employee.Gender switch
|
||
{
|
||
"Мужской" => 1,
|
||
"Женский" => 2,
|
||
_ => 0
|
||
}
|
||
};
|
||
var content = new StackPanel { Spacing = 12 };
|
||
content.Children.Add(personnelNumberBox);
|
||
content.Children.Add(nameBox);
|
||
content.Children.Add(positionBox);
|
||
content.Children.Add(genderBox);
|
||
|
||
var dialog = new ContentDialog
|
||
{
|
||
XamlRoot = XamlRoot,
|
||
Title = $"Редактирование: {employee.DisplayName}",
|
||
Content = content,
|
||
PrimaryButtonText = "Сохранить",
|
||
CloseButtonText = "Отмена",
|
||
DefaultButton = ContentDialogButton.Primary
|
||
};
|
||
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
|
||
|
||
var number = personnelNumberBox.Text.Trim();
|
||
var name = nameBox.Text.Trim();
|
||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(number))
|
||
{
|
||
SetStatus("Имя и табельный номер не могут быть пустыми.", InfoBarSeverity.Warning);
|
||
return;
|
||
}
|
||
if (_state.Employees.Any(item => !ReferenceEquals(item, employee) &&
|
||
item.PersonnelNumber.Equals(number, StringComparison.OrdinalIgnoreCase)))
|
||
{
|
||
SetStatus("Сотрудник с таким табельным номером уже есть.", InfoBarSeverity.Warning);
|
||
return;
|
||
}
|
||
|
||
employee.PersonnelNumber = number;
|
||
employee.DisplayName = name;
|
||
employee.Position = positionBox.Text.Trim();
|
||
employee.Gender = genderBox.SelectedIndex switch
|
||
{
|
||
1 => "Мужской",
|
||
2 => "Женский",
|
||
_ => ""
|
||
};
|
||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||
RefreshLists();
|
||
EmployeesList.SelectedItem = employee;
|
||
SetStatus($"Данные сотрудника «{name}» обновлены. Отпечатки и журнал сохранены.", InfoBarSeverity.Success);
|
||
}
|
||
|
||
private async void EditEmployeeNotifications_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (!await EnsureAdminSessionAsync("настройка личных уведомлений")) return;
|
||
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
|
||
{
|
||
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
|
||
return;
|
||
}
|
||
if (employee.IsBlocked)
|
||
{
|
||
SetStatus("Сначала разблокируйте сотрудника.", InfoBarSeverity.Warning);
|
||
return;
|
||
}
|
||
|
||
var telegramBox = new TextBox
|
||
{
|
||
Header = "Telegram — личный chat_id",
|
||
Text = employee.TelegramChatId,
|
||
PlaceholderText = "Например, 123456789",
|
||
MinWidth = 420
|
||
};
|
||
var vkBox = new TextBox
|
||
{
|
||
Header = "ВКонтакте — личный peer_id",
|
||
Text = employee.VkPeerId,
|
||
PlaceholderText = "ID пользователя в диалоге с сообществом"
|
||
};
|
||
var maxBox = new TextBox
|
||
{
|
||
Header = "MAX — личный user_id",
|
||
Text = employee.MaxUserId,
|
||
PlaceholderText = "ID пользователя в диалоге с ботом"
|
||
};
|
||
var content = new StackPanel { Spacing = 12 };
|
||
content.Children.Add(new TextBlock
|
||
{
|
||
Text = "Сообщения отправляются только в личный диалог этого сотрудника. Перед привязкой сотрудник должен открыть соответствующего бота и начать диалог.",
|
||
TextWrapping = TextWrapping.Wrap
|
||
});
|
||
content.Children.Add(telegramBox);
|
||
content.Children.Add(vkBox);
|
||
content.Children.Add(maxBox);
|
||
|
||
var dialog = new ContentDialog
|
||
{
|
||
XamlRoot = XamlRoot,
|
||
Title = $"Личные уведомления: {employee.DisplayName}",
|
||
Content = content,
|
||
PrimaryButtonText = "Сохранить",
|
||
CloseButtonText = "Отмена",
|
||
DefaultButton = ContentDialogButton.Primary
|
||
};
|
||
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
|
||
|
||
employee.TelegramChatId = telegramBox.Text.Trim();
|
||
employee.VkPeerId = vkBox.Text.Trim();
|
||
employee.MaxUserId = maxBox.Text.Trim();
|
||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||
RefreshLists();
|
||
EmployeesList.SelectedItem = employee;
|
||
SetStatus($"Личные уведомления сотрудника «{employee.DisplayName}» сохранены: {employee.NotificationStatus}.", InfoBarSeverity.Success);
|
||
}
|
||
|
||
private async void ToggleEmployeeBlocked_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (!await EnsureAdminSessionAsync("блокировка сотрудника")) return;
|
||
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
|
||
{
|
||
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
|
||
return;
|
||
}
|
||
|
||
if (!employee.IsBlocked && employee.IsAdministrator &&
|
||
_state.Employees.Count(item => item.IsAdministrator && !item.IsBlocked) <= 1)
|
||
{
|
||
SetStatus("Нельзя заблокировать единственного активного администратора.", InfoBarSeverity.Warning);
|
||
return;
|
||
}
|
||
|
||
if (!employee.IsBlocked)
|
||
{
|
||
var dialog = new ContentDialog
|
||
{
|
||
XamlRoot = XamlRoot,
|
||
Title = $"Заблокировать {employee.DisplayName}?",
|
||
Content = "Отпечатки перестанут создавать отметки. Привязки Telegram, ВКонтакте и MAX будут удалены, новые сообщения отправляться не будут. История диалога в самом мессенджере останется у пользователя.",
|
||
PrimaryButtonText = "Заблокировать",
|
||
CloseButtonText = "Отмена",
|
||
DefaultButton = ContentDialogButton.Close
|
||
};
|
||
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
|
||
if (!await AuthorizeAdminAsync("деактивация сотрудника")) return;
|
||
|
||
employee.IsBlocked = true;
|
||
employee.BlockedAtUtc = DateTimeOffset.UtcNow;
|
||
employee.TelegramChatId = "";
|
||
employee.VkPeerId = "";
|
||
employee.MaxUserId = "";
|
||
}
|
||
else
|
||
{
|
||
employee.IsBlocked = false;
|
||
employee.BlockedAtUtc = null;
|
||
}
|
||
|
||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||
RefreshLists();
|
||
EmployeesList.SelectedItem = employee;
|
||
ConnectScanner();
|
||
SetStatus(employee.IsBlocked
|
||
? $"Сотрудник «{employee.DisplayName}» заблокирован, личные каналы отвязаны."
|
||
: $"Сотрудник «{employee.DisplayName}» разблокирован. Личные уведомления нужно подключить заново.",
|
||
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 fingerCombo = new ComboBox
|
||
{
|
||
Header = "Выберите палец для регистрации",
|
||
ItemsSource = new[]
|
||
{
|
||
"Указательный (правая рука)",
|
||
"Большой (правая рука)",
|
||
"Средний (правая рука)",
|
||
"Безымянный (правая рука)",
|
||
"Мизинец (правая рука)",
|
||
"Указательный (левая рука)",
|
||
"Большой (левая рука)",
|
||
"Средний (левая рука)",
|
||
"Безымянный (левая рука)",
|
||
"Мизинец (левая рука)",
|
||
"Дополнительный отпечаток"
|
||
},
|
||
SelectedIndex = 0,
|
||
MinWidth = 280
|
||
};
|
||
|
||
var wizardDialog = new ContentDialog
|
||
{
|
||
XamlRoot = XamlRoot,
|
||
Title = $"Мастер регистрации отпечатка: {employee.DisplayName}",
|
||
Content = new StackPanel
|
||
{
|
||
Spacing = 12,
|
||
Children =
|
||
{
|
||
new TextBlock
|
||
{
|
||
Text = "Шаг 1 из 3: Выберите палец для сканирования.\nПосле нажатия «Начать» приложите палец к сканеру 3 раза.",
|
||
TextWrapping = TextWrapping.Wrap
|
||
},
|
||
fingerCombo
|
||
}
|
||
},
|
||
PrimaryButtonText = "Начать регистрацию",
|
||
CloseButtonText = "Отмена",
|
||
DefaultButton = ContentDialogButton.Primary
|
||
};
|
||
|
||
if (await wizardDialog.ShowAsync() != ContentDialogResult.Primary) return;
|
||
|
||
_registrationFingerLabel = (string)fingerCombo.SelectedItem;
|
||
|
||
var fingerprintId = _state.Employees
|
||
.SelectMany(item => item.Fingerprints)
|
||
.Select(item => item.Id)
|
||
.DefaultIfEmpty(0)
|
||
.Max() + 1;
|
||
_registrationEmployeeId = employee.Id;
|
||
_scanner.BeginRegistration(fingerprintId);
|
||
SetStatus($"Мастер регистрации: приложите {_registrationFingerLabel} к сканеру 3 раза.", InfoBarSeverity.Informational);
|
||
}
|
||
|
||
private void CancelRegistration_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_registrationEmployeeId is null)
|
||
{
|
||
SetStatus("Активной регистрации отпечатка нет.", InfoBarSeverity.Informational);
|
||
return;
|
||
}
|
||
CancelRegistration();
|
||
SetStatus("Регистрация отпечатка отменена.", InfoBarSeverity.Informational);
|
||
}
|
||
|
||
private void EmployeesList_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||
{
|
||
if (_refreshingLists) return;
|
||
if (_registrationEmployeeId is null) return;
|
||
if (EmployeesList.SelectedItem is EmployeeRecord employee && employee.Id == _registrationEmployeeId) return;
|
||
CancelRegistration();
|
||
SetStatus("Регистрация отпечатка отменена из-за смены сотрудника.", InfoBarSeverity.Warning);
|
||
}
|
||
|
||
private void CancelRegistration()
|
||
{
|
||
_scanner.CancelRegistration();
|
||
_registrationEmployeeId = null;
|
||
}
|
||
|
||
private async Task CompleteRegistrationAsync(int fingerprintId, string template)
|
||
{
|
||
var employee = _state.Employees.FirstOrDefault(item => item.Id == _registrationEmployeeId);
|
||
if (employee is null) return;
|
||
|
||
var existingSlot = employee.Fingerprints.FirstOrDefault(f => f.Label == _registrationFingerLabel);
|
||
if (existingSlot is not null)
|
||
{
|
||
existingSlot.Id = fingerprintId;
|
||
existingSlot.Template = template;
|
||
existingSlot.EnrolledAtUtc = DateTimeOffset.UtcNow;
|
||
}
|
||
else
|
||
{
|
||
employee.Fingerprints.Add(new FingerprintRecord
|
||
{
|
||
Id = fingerprintId,
|
||
Template = template,
|
||
Label = _registrationFingerLabel,
|
||
EnrolledAtUtc = DateTimeOffset.UtcNow
|
||
});
|
||
}
|
||
|
||
_registrationEmployeeId = null;
|
||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||
RefreshLists();
|
||
ConnectScanner();
|
||
TerminalMessage.Text = $"Отпечаток «{_registrationFingerLabel}» сотрудника «{TerminalDisplayName(employee)}» успешно сохранён";
|
||
SetStatus($"Отпечаток «{_registrationFingerLabel}» сотрудника «{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;
|
||
}
|
||
if (employee.IsAdministrator && !employee.IsBlocked && !PinConfigured &&
|
||
_state.Employees.Count(item => item.IsAdministrator && !item.IsBlocked && item.HasFingerprint) <= 1)
|
||
{
|
||
SetStatus("Нельзя удалить последний способ входа администратора.", InfoBarSeverity.Warning);
|
||
return;
|
||
}
|
||
|
||
var options = new List<string> { "Все зарегистрированные отпечатки" };
|
||
options.AddRange(employee.Fingerprints.Select(f => $"{f.Label} (ID {f.Id})"));
|
||
|
||
var combo = new ComboBox
|
||
{
|
||
Header = "Выберите отпечаток для удаления",
|
||
ItemsSource = options,
|
||
SelectedIndex = 0,
|
||
MinWidth = 280
|
||
};
|
||
|
||
var dialog = new ContentDialog
|
||
{
|
||
XamlRoot = XamlRoot,
|
||
Title = $"Удаление отпечатков: {employee.DisplayName}",
|
||
Content = new StackPanel { Spacing = 10, Children = { combo } },
|
||
PrimaryButtonText = "Удалить",
|
||
CloseButtonText = "Отмена",
|
||
DefaultButton = ContentDialogButton.Close
|
||
};
|
||
|
||
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
|
||
if (!await AuthorizeAdminAsync("удаление отпечатка сотрудника")) return;
|
||
|
||
if (combo.SelectedIndex == 0)
|
||
{
|
||
employee.Fingerprints.Clear();
|
||
SetStatus($"Все отпечатки сотрудника «{employee.DisplayName}» удалены.", InfoBarSeverity.Success);
|
||
}
|
||
else
|
||
{
|
||
var chosenFingerprint = employee.Fingerprints[combo.SelectedIndex - 1];
|
||
employee.Fingerprints.RemoveAt(combo.SelectedIndex - 1);
|
||
SetStatus($"Отпечаток «{chosenFingerprint.Label}» сотрудника «{employee.DisplayName}» удален.", InfoBarSeverity.Success);
|
||
}
|
||
|
||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||
RefreshLists();
|
||
ConnectScanner();
|
||
}
|
||
|
||
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 (AdministratorEmployeeConfigured && !await AuthorizeAdminAsync("изменение прав администратора")) return;
|
||
if (employee.IsAdministrator && !employee.IsBlocked &&
|
||
_state.Employees.Count(item => item.IsAdministrator && !item.IsBlocked) <= 1)
|
||
{
|
||
SetStatus("Нельзя снять права с единственного администратора с зарегистрированным отпечатком.", InfoBarSeverity.Warning);
|
||
return;
|
||
}
|
||
employee.IsAdministrator = !employee.IsAdministrator;
|
||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||
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 && !employee.IsBlocked &&
|
||
_state.Employees.Count(item => item.IsAdministrator && !item.IsBlocked) <= 1)
|
||
{
|
||
SetStatus("Нельзя удалить единственного администратора.", InfoBarSeverity.Warning);
|
||
return;
|
||
}
|
||
|
||
_state.Employees.Remove(employee);
|
||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||
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 (employee.IsBlocked)
|
||
{
|
||
if (_applicationLocked) LockScreenMessage.Text = "Этот сотрудник заблокирован.";
|
||
else SetStatus("Сотрудник заблокирован. Отметка не создана.", InfoBarSeverity.Warning);
|
||
return;
|
||
}
|
||
|
||
if (_adminAuthCompletion is not null)
|
||
{
|
||
if (employee.IsAdministrator)
|
||
{
|
||
// Some SDK/reader combinations can report the same touch more
|
||
// than once while the authorization dialog is closing. Keep the
|
||
// follow-up report out of attendance processing.
|
||
_attendanceSuppressedFingerprintId = fingerprintId;
|
||
_attendanceSuppressedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(3);
|
||
_lastAdminActor = $"employee:{employee.Id}";
|
||
TerminalMessage.Text = $"Администратор {TerminalDisplayName(employee)} подтверждён";
|
||
_adminAuthCompletion.TrySetResult(true);
|
||
_adminAuthDialog?.Hide();
|
||
}
|
||
else
|
||
{
|
||
SetStatus("Этот отпечаток не принадлежит администратору. Отметка прихода или ухода не создана.", InfoBarSeverity.Warning);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (_attendanceSuppressedFingerprintId == fingerprintId &&
|
||
DateTimeOffset.UtcNow < _attendanceSuppressedUntilUtc)
|
||
{
|
||
return;
|
||
}
|
||
_attendanceSuppressedFingerprintId = null;
|
||
_attendanceSuppressedUntilUtc = default;
|
||
|
||
if (_applicationLocked)
|
||
{
|
||
var scanResult = await RecordAttendanceAsync(employee, score);
|
||
if (scanResult.ShouldUnlock)
|
||
{
|
||
_attendanceSuppressedFingerprintId = fingerprintId;
|
||
_attendanceSuppressedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(3);
|
||
UnlockApplication($"employee:{employee.Id}", scanResult.StatusMessage);
|
||
return;
|
||
}
|
||
|
||
LockScreenMessage.Text = scanResult.LockScreenMessage;
|
||
return;
|
||
}
|
||
|
||
await RecordAttendanceAsync(employee, score);
|
||
}
|
||
|
||
private async Task<ScanRecognitionResult> RecordAttendanceAsync(EmployeeRecord employee, int score)
|
||
{
|
||
var now = DateTimeOffset.UtcNow;
|
||
var scanResult = await _attendanceTracker.ProcessScanAsync(
|
||
_state,
|
||
employee,
|
||
_scanner.ReaderIndex,
|
||
now,
|
||
_applicationLocked,
|
||
state => _store.SaveAsync(state));
|
||
|
||
var result = scanResult.AttendanceResult;
|
||
if (!result.Success)
|
||
{
|
||
if (result.IsDuplicateSuppressed)
|
||
{
|
||
PlayDuplicateSoundSafe();
|
||
ShowTrayNotification(
|
||
"Krona Staff",
|
||
$"Повторный скан отклонён. Повторите через {result.RemainingSeconds} сек.");
|
||
TerminalMessage.Text = $"{TerminalDisplayName(employee)}\nПовторный скан отклонён\nПовторите через {result.RemainingSeconds} сек.";
|
||
SetStatus(result.Message, InfoBarSeverity.Informational);
|
||
}
|
||
return scanResult;
|
||
}
|
||
|
||
var record = result.Record!;
|
||
PlaySuccessSoundSafe();
|
||
ShowTrayNotification(
|
||
"Krona Staff",
|
||
$"{TerminalDisplayName(employee)}: {record.EventType} в {now.ToLocalTime():HH:mm}");
|
||
|
||
RefreshLists();
|
||
TerminalMessage.Text = $"{TerminalDisplayName(employee)}\n{record.EventType} отмечен в {now.ToLocalTime():HH:mm}";
|
||
SetStatus($"{record.EventType} отмечен. Точность распознавания: {score}.", InfoBarSeverity.Success);
|
||
|
||
var delivery = await _notifications.SendAttendanceAsync(_state.Settings, employee, record.EventType, now.ToLocalTime());
|
||
record.NotificationDelivered = delivery.Delivered;
|
||
record.NotificationError = delivery.Error;
|
||
|
||
// Try IPC enqueue first for background service
|
||
_ = _ipcClient.EnqueueAttendanceAsync(record);
|
||
|
||
await SyncPendingAttendanceAsync();
|
||
await _store.SaveAsync(_state);
|
||
RefreshLists();
|
||
if (delivery.Error is not null)
|
||
{
|
||
SetStatus($"{record.EventType} сохранён, но уведомление не отправлено: {delivery.Error}", InfoBarSeverity.Warning);
|
||
}
|
||
|
||
return scanResult;
|
||
}
|
||
|
||
private static void PlaySuccessSoundSafe()
|
||
{
|
||
try
|
||
{
|
||
System.Media.SystemSounds.Asterisk.Play();
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
CrashLogService.Write(exception);
|
||
}
|
||
}
|
||
|
||
private static void PlayDuplicateSoundSafe()
|
||
{
|
||
try
|
||
{
|
||
System.Media.SystemSounds.Exclamation.Play();
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
CrashLogService.Write(exception);
|
||
}
|
||
}
|
||
|
||
private static void ShowTrayNotification(string title, string message)
|
||
{
|
||
try
|
||
{
|
||
if (Application.Current is App app)
|
||
{
|
||
app.ShowNotificationBalloon(title, message);
|
||
}
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
CrashLogService.Write(exception);
|
||
}
|
||
}
|
||
|
||
private async Task RunAutomaticCheckoutIfDueAsync()
|
||
{
|
||
if (!_loaded || _stateLoadFailed || _automaticCheckoutInProgress ||
|
||
!_state.Settings.AutomaticCheckoutEnabled)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_automaticCheckoutInProgress = true;
|
||
try
|
||
{
|
||
var nowUtc = DateTimeOffset.UtcNow;
|
||
var created = await _attendanceTracker.RunScheduledCheckoutAsync(
|
||
_state,
|
||
nowUtc,
|
||
state => _store.SaveAsync(state));
|
||
|
||
if (created.Count == 0) return;
|
||
|
||
AppendSecurityAudit(
|
||
"attendance.automatic_checkout",
|
||
"succeeded",
|
||
$"scheduled={nowUtc:O}; records={created.Count}");
|
||
|
||
RefreshLists();
|
||
TerminalMessage.Text = $"Автоматический уход\nЗакрыто смен: {created.Count}";
|
||
SetStatus($"Автоматически закрыто смен: {created.Count}.", InfoBarSeverity.Informational);
|
||
|
||
foreach (var record in created)
|
||
{
|
||
_ = _ipcClient.EnqueueAttendanceAsync(record);
|
||
var employee = _state.Employees.FirstOrDefault(e => e.Id == record.EmployeeId);
|
||
if (employee is not null)
|
||
{
|
||
var delivery = await _notifications.SendAttendanceAsync(
|
||
_state.Settings,
|
||
employee,
|
||
record.EventType,
|
||
record.OccurredAtUtc.ToLocalTime());
|
||
record.NotificationDelivered = delivery.Delivered;
|
||
record.NotificationError = delivery.Error;
|
||
}
|
||
}
|
||
|
||
await SyncPendingAttendanceAsync();
|
||
await _store.SaveAsync(_state);
|
||
RefreshLists();
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
CrashLogService.Write(exception);
|
||
SetStatus($"Автоматический уход не выполнен: {exception.Message}", InfoBarSeverity.Warning);
|
||
}
|
||
finally
|
||
{
|
||
_automaticCheckoutInProgress = false;
|
||
}
|
||
}
|
||
|
||
private static DateTimeOffset LatestAutomaticCheckoutScheduleUtc(DateTimeOffset nowUtc, int hour, int minute)
|
||
{
|
||
var localNow = nowUtc.ToLocalTime();
|
||
var localSchedule = localNow.Date.AddHours(hour).AddMinutes(minute);
|
||
if (localSchedule > localNow.DateTime) localSchedule = localSchedule.AddDays(-1);
|
||
return LocalScheduleToUtc(localSchedule);
|
||
}
|
||
|
||
private static DateTimeOffset NextAutomaticCheckoutAfter(DateTimeOffset arrivalUtc, int hour, int minute)
|
||
{
|
||
var localArrival = arrivalUtc.ToLocalTime();
|
||
var localSchedule = localArrival.Date.AddHours(hour).AddMinutes(minute);
|
||
if (localSchedule <= localArrival.DateTime) localSchedule = localSchedule.AddDays(1);
|
||
return LocalScheduleToUtc(localSchedule);
|
||
}
|
||
|
||
private static DateTimeOffset LocalScheduleToUtc(DateTime localSchedule)
|
||
{
|
||
localSchedule = DateTime.SpecifyKind(localSchedule, DateTimeKind.Unspecified);
|
||
if (TimeZoneInfo.Local.IsInvalidTime(localSchedule)) localSchedule = localSchedule.AddHours(1);
|
||
return new DateTimeOffset(TimeZoneInfo.ConvertTimeToUtc(localSchedule, TimeZoneInfo.Local));
|
||
}
|
||
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, createRecoveryPoint: true);
|
||
RefreshLists();
|
||
TerminalMessage.Text = "Журнал отметок очищен";
|
||
SetStatus("Журнал прихода и ухода очищен.", InfoBarSeverity.Success);
|
||
}
|
||
|
||
private async void ExportAttendance_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (!await EnsureAdminSessionAsync("экспорт журнала")) return;
|
||
var warning = new ContentDialog
|
||
{
|
||
XamlRoot = XamlRoot,
|
||
Title = "Экспорт персональных данных",
|
||
Content = "CSV-файл не шифруется и содержит ФИО, табельные номера, должности и время отметок. Сохраняйте его только в защищённом месте.",
|
||
PrimaryButtonText = "Продолжить экспорт",
|
||
CloseButtonText = "Отмена",
|
||
DefaultButton = ContentDialogButton.Close
|
||
};
|
||
if (await warning.ShowAsync() != ContentDialogResult.Primary) return;
|
||
_lastAdminActor = "unknown";
|
||
if (!await AuthorizeAdminAsync("экспорт персональных данных", requireIdentifiedActor: true)) return;
|
||
|
||
var picker = new FileSavePicker
|
||
{
|
||
SuggestedFileName = $"krona-staff-attendance-{DateTime.Now:yyyy-MM-dd}"
|
||
};
|
||
picker.FileTypeChoices.Add("CSV", new[] { ".csv" });
|
||
WinRT.Interop.InitializeWithWindow.Initialize(picker, App.WindowHandle);
|
||
var file = await picker.PickSaveFileAsync();
|
||
if (file is null) return;
|
||
|
||
AppendSecurityAudit("attendance.export", "started", $"records={_state.Attendance.Count}; file={file.Name}");
|
||
await _store.SaveAsync(_state);
|
||
try
|
||
{
|
||
var csv = new StringBuilder("Сотрудник;Табельный номер;Должность;Событие;Время;Доставка\r\n");
|
||
foreach (var item in _state.Attendance.OrderBy(record => record.OccurredAtUtc))
|
||
{
|
||
var employee = _state.Employees.FirstOrDefault(candidate => candidate.Id == item.EmployeeId);
|
||
csv.Append(Csv(item.EmployeeName)).Append(';')
|
||
.Append(Csv(employee?.PersonnelNumber ?? "")).Append(';')
|
||
.Append(Csv(employee?.Position ?? "")).Append(';')
|
||
.Append(Csv(item.EventType)).Append(';')
|
||
.Append(Csv(item.LocalTime)).Append(';')
|
||
.Append(Csv(item.DeliveryStatus)).Append("\r\n");
|
||
}
|
||
await FileIO.WriteTextAsync(file, csv.ToString(), Windows.Storage.Streams.UnicodeEncoding.Utf8);
|
||
AppendSecurityAudit("attendance.export", "succeeded", $"records={_state.Attendance.Count}; file={file.Name}");
|
||
await _store.SaveAsync(_state);
|
||
SetStatus($"Журнал экспортирован: {file.Name}", InfoBarSeverity.Success);
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
AppendSecurityAudit("attendance.export", "failed", $"records={_state.Attendance.Count}; error={exception.GetType().Name}");
|
||
await _store.SaveAsync(_state);
|
||
CrashLogService.Write(exception);
|
||
SetStatus($"Экспорт не выполнен: {exception.Message}", InfoBarSeverity.Error);
|
||
}
|
||
}
|
||
|
||
private static string Csv(string value) => $"\"{value.Replace("\"", "\"\"")}\"";
|
||
|
||
public async Task<bool> AuthorizeExitAsync()
|
||
{
|
||
if (_stateLoadFailed)
|
||
{
|
||
SetStatus("Хранилище не открыто. Программа завершает работу без изменения данных.", InfoBarSeverity.Warning);
|
||
return true;
|
||
}
|
||
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.IsBlocked && employee.HasFingerprint);
|
||
|
||
private bool AdministratorEmployeeConfigured =>
|
||
_state.Employees.Any(employee => employee.IsAdministrator && !employee.IsBlocked);
|
||
|
||
private bool SecurityConfigured =>
|
||
PinConfigured || AdministratorFingerprintConfigured;
|
||
|
||
private async Task<bool> AuthorizeAdminAsync(string operation, bool requireIdentifiedActor = false)
|
||
{
|
||
var remainingLockout = _state.Settings.AdminPinBlockedUntilUtc - DateTimeOffset.UtcNow;
|
||
var pinBlocked = remainingLockout > TimeSpan.Zero;
|
||
var hasAdministratorFingerprint = AdministratorFingerprintConfigured;
|
||
if (requireIdentifiedActor && !hasAdministratorFingerprint)
|
||
{
|
||
SetStatus("Для экспорта персональных данных требуется отпечаток сотрудника-администратора.", InfoBarSeverity.Warning);
|
||
return false;
|
||
}
|
||
if (pinBlocked && !hasAdministratorFingerprint)
|
||
{
|
||
SetStatus($"Слишком много неверных попыток PIN. Повторите через {Math.Ceiling(remainingLockout.TotalSeconds)} сек.", InfoBarSeverity.Warning);
|
||
return false;
|
||
}
|
||
var pinBox = new PasswordBox
|
||
{
|
||
Header = "PIN администратора",
|
||
PasswordRevealMode = PasswordRevealMode.Peek,
|
||
MinWidth = 280,
|
||
IsEnabled = !pinBlocked && !requireIdentifiedActor
|
||
};
|
||
var content = new StackPanel { Spacing = 12, MinWidth = 320 };
|
||
content.Children.Add(new TextBlock
|
||
{
|
||
Text = requireIdentifiedActor
|
||
? "Для записи автора экспорта приложите зарегистрированный палец сотрудника-администратора."
|
||
: hasAdministratorFingerprint
|
||
? pinBlocked
|
||
? $"PIN временно заблокирован ещё на {Math.Ceiling(remainingLockout.TotalSeconds)} сек. Приложите зарегистрированный палец администратора."
|
||
: "Введите 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",
|
||
IsPrimaryButtonEnabled = !pinBlocked && !requireIdentifiedActor,
|
||
CloseButtonText = "Отмена",
|
||
DefaultButton = ContentDialogButton.Primary
|
||
};
|
||
var dialogResult = await _adminAuthDialog.ShowAsync();
|
||
var fingerprintAuthorized = _adminAuthCompletion.Task.IsCompletedSuccessfully &&
|
||
_adminAuthCompletion.Task.Result;
|
||
_adminAuthCompletion.TrySetResult(false);
|
||
var pinAuthorized = false;
|
||
// PasswordBox is a WinUI object and must only be accessed from the UI
|
||
// thread. Capture its value before performing PBKDF2 verification.
|
||
var enteredPin = pinBox.Password;
|
||
string? pinErrorMessage = null;
|
||
var securityStateChanged = false;
|
||
|
||
if (dialogResult == ContentDialogResult.Primary && !pinBlocked && !requireIdentifiedActor)
|
||
{
|
||
pinAuthorized = AdminPinService.VerifyAndRecord(
|
||
enteredPin,
|
||
_state.Settings,
|
||
DateTimeOffset.UtcNow,
|
||
out pinErrorMessage,
|
||
out _);
|
||
|
||
if (!pinAuthorized && !string.Equals(enteredPin, enteredPin.Trim(), StringComparison.Ordinal))
|
||
{
|
||
pinAuthorized = AdminPinService.VerifyAndRecord(
|
||
enteredPin.Trim(),
|
||
_state.Settings,
|
||
DateTimeOffset.UtcNow,
|
||
out pinErrorMessage,
|
||
out _);
|
||
}
|
||
securityStateChanged = true;
|
||
if (pinAuthorized)
|
||
{
|
||
_lastAdminActor = "local-pin";
|
||
}
|
||
}
|
||
else if (fingerprintAuthorized)
|
||
{
|
||
_state.Settings.FailedAdminPinAttempts = 0;
|
||
_state.Settings.AdminPinBlockedUntilUtc = default;
|
||
securityStateChanged = true;
|
||
}
|
||
|
||
var authorized = fingerprintAuthorized || pinAuthorized;
|
||
if (securityStateChanged) await _store.SaveAsync(_state);
|
||
_adminAuthCompletion = null;
|
||
_adminAuthDialog = null;
|
||
|
||
SetStatus(authorized
|
||
? fingerprintAuthorized ? "Вход выполнен по отпечатку администратора." : "Вход выполнен по PIN администратора."
|
||
: dialogResult == ContentDialogResult.Primary ? (pinErrorMessage ?? "Неверный 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 (AdministratorEmployeeConfigured && PinConfigured && !await AuthorizeAdminAsync("изменение PIN")) return;
|
||
var pin = await Task.Run(() => AdminPinService.Hash(newPin));
|
||
_state.Settings.AdminPinSalt = pin.Salt;
|
||
_state.Settings.AdminPinHash = pin.Hash;
|
||
_state.Settings.AdminPinIterations = pin.Iterations;
|
||
NewAdminPinBox.Password = "";
|
||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||
SetStatus("Новый PIN администратора сохранён.", InfoBarSeverity.Success);
|
||
}
|
||
|
||
private async void SaveSettings_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
// The settings tab itself is protected by the active admin session.
|
||
// Saving must not start a second fingerprint authorization flow.
|
||
if (!HasActiveAdminSession)
|
||
{
|
||
LockAdminSession("Сеанс администратора завершён. Войдите снова, чтобы сохранить настройки.");
|
||
return;
|
||
}
|
||
TouchAdminSession();
|
||
CopySettingsFromControls();
|
||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||
ApplyStartupSetting();
|
||
ConnectScanner();
|
||
await RunAutomaticCheckoutIfDueAsync();
|
||
SetStatus("Настройки сохранены.", InfoBarSeverity.Success);
|
||
}
|
||
|
||
private async void CheckForUpdates_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (!await EnsureAdminSessionAsync("проверка обновлений")) return;
|
||
CopyUpdateSettingsFromControls();
|
||
await _store.SaveAsync(_state);
|
||
await CheckForUpdatesAsync(true);
|
||
}
|
||
|
||
private async Task CheckForUpdatesAsync(bool reportUpToDate)
|
||
{
|
||
if (_updateCheckInProgress ||
|
||
(!_state.Settings.UpdateChecksEnabled && !reportUpToDate) ||
|
||
_updateCancellation.IsCancellationRequested)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_updateCheckInProgress = true;
|
||
try
|
||
{
|
||
var update = await _updateSource.FindUpdateAsync(
|
||
_state.Settings,
|
||
_updateCancellation.Token);
|
||
_state.Settings.LastUpdateCheckUtc = DateTimeOffset.UtcNow;
|
||
if (update is null)
|
||
{
|
||
_availableUpdate = null;
|
||
UpdateBar.IsOpen = false;
|
||
if (reportUpToDate)
|
||
{
|
||
SetStatus(
|
||
$"Установлена последняя версия {ForgejoUpdateSource.CurrentVersion.ToString(3)}.",
|
||
InfoBarSeverity.Success);
|
||
}
|
||
return;
|
||
}
|
||
|
||
_availableUpdate = update;
|
||
UpdateBar.Message = $"{update.ReleaseName}. Установка доступна администратору.";
|
||
UpdateBar.IsOpen = true;
|
||
if (reportUpToDate)
|
||
{
|
||
SetStatus($"Доступна версия {update.DisplayVersion}.", InfoBarSeverity.Informational);
|
||
}
|
||
}
|
||
catch (OperationCanceledException) when (_updateCancellation.IsCancellationRequested)
|
||
{
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
CrashLogService.Write(exception);
|
||
if (reportUpToDate)
|
||
{
|
||
SetStatus($"Не удалось проверить обновления: {exception.Message}", InfoBarSeverity.Warning);
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
_updateCheckInProgress = false;
|
||
}
|
||
}
|
||
|
||
private async void InstallUpdate_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_availableUpdate is null) return;
|
||
if (!await EnsureAdminSessionAsync("установка обновления")) return;
|
||
|
||
var update = _availableUpdate;
|
||
var notes = string.IsNullOrWhiteSpace(update.ReleaseNotes)
|
||
? "После загрузки программа закроется и откроется установщик."
|
||
: update.ReleaseNotes;
|
||
var dialog = new ContentDialog
|
||
{
|
||
XamlRoot = XamlRoot,
|
||
Title = $"Установить Krona Staff {update.DisplayVersion}?",
|
||
Content = new TextBlock
|
||
{
|
||
Text = notes,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
MaxWidth = 520
|
||
},
|
||
PrimaryButtonText = "Скачать и установить",
|
||
CloseButtonText = "Позже",
|
||
DefaultButton = ContentDialogButton.Primary
|
||
};
|
||
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
|
||
|
||
try
|
||
{
|
||
UpdateBar.IsClosable = false;
|
||
UpdateBar.Message = $"Загрузка версии {update.DisplayVersion}: 0%";
|
||
var progress = new Progress<double>(value =>
|
||
UpdateBar.Message = $"Загрузка версии {update.DisplayVersion}: {value:P0}");
|
||
var installerPath = await _updateSource.DownloadAndVerifyAsync(
|
||
update,
|
||
_state.Settings,
|
||
progress,
|
||
_updateCancellation.Token);
|
||
AppendSecurityAudit("application.update", "started", $"version={update.DisplayVersion}");
|
||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||
ForgejoUpdateSource.LaunchInstaller(installerPath);
|
||
((App)Application.Current).CompleteExit();
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
CrashLogService.Write(exception);
|
||
UpdateBar.IsClosable = true;
|
||
SetStatus($"Обновление не установлено: {exception.Message}", InfoBarSeverity.Error);
|
||
}
|
||
}
|
||
|
||
private async void TestNotification_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (!await EnsureAdminSessionAsync("проверка уведомлений")) return;
|
||
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
|
||
{
|
||
SetStatus("Выберите сотрудника в разделе «Сотрудники», затем повторите тест.", InfoBarSeverity.Warning);
|
||
return;
|
||
}
|
||
CopySettingsFromControls();
|
||
var result = await _notifications.SendTestAsync(_state.Settings, employee);
|
||
SetStatus(result.Delivered
|
||
? $"Тестовое сообщение отправлено лично сотруднику «{employee.DisplayName}»."
|
||
: result.Error ?? "У сотрудника не указан личный ID или канал выключен.",
|
||
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.MaximumShiftHours = double.IsNaN(MaximumShiftHoursBox.Value) ? 20 : Math.Clamp((int)MaximumShiftHoursBox.Value, 1, 48);
|
||
_state.Settings.AutomaticCheckoutEnabled = AutomaticCheckoutSwitch.IsOn;
|
||
_state.Settings.AutomaticCheckoutHour = Math.Clamp(AutomaticCheckoutTimePicker.Time.Hours, 0, 23);
|
||
_state.Settings.AutomaticCheckoutMinute = Math.Clamp(AutomaticCheckoutTimePicker.Time.Minutes, 0, 59);
|
||
_state.Settings.ShowEmployeeNamesOnTerminal = ShowEmployeeNamesSwitch.IsOn;
|
||
_state.Settings.TelegramEnabled = TelegramEnabledSwitch.IsOn;
|
||
_state.Settings.TelegramBotToken = TelegramTokenBox.Password.Trim();
|
||
_state.Settings.VkEnabled = VkEnabledSwitch.IsOn;
|
||
_state.Settings.VkGroupToken = VkTokenBox.Password.Trim();
|
||
_state.Settings.MaxEnabled = MaxEnabledSwitch.IsOn;
|
||
_state.Settings.MaxBotToken = MaxTokenBox.Password.Trim();
|
||
_state.Settings.StaffServerUrl = StaffServerUrlBox.Text.Trim();
|
||
_state.Settings.StaffServerToken = StaffServerTokenBox.Password.Trim();
|
||
_state.Settings.StaffServerCertificateSha256 = StaffServerCertificateBox.Text.Trim();
|
||
CopyUpdateSettingsFromControls();
|
||
}
|
||
|
||
private void CopyUpdateSettingsFromControls()
|
||
{
|
||
_state.Settings.UpdateChecksEnabled = UpdateChecksSwitch.IsOn;
|
||
_state.Settings.UpdateFeedUrl = UpdateFeedBox.Text.Trim();
|
||
_state.Settings.UpdateAccessToken = UpdateTokenBox.Password.Trim();
|
||
_state.Settings.UpdateTrustedSignerThumbprint = UpdateSignerThumbprintBox.Text.Trim();
|
||
}
|
||
|
||
private void LoadSettings()
|
||
{
|
||
StartWithWindowsSwitch.IsOn = _state.Settings.StartWithWindows;
|
||
DuplicateGuardBox.Value = Math.Clamp(_state.Settings.DuplicateScanGuardSeconds, 10, 3600);
|
||
MaximumShiftHoursBox.Value = Math.Clamp(_state.Settings.MaximumShiftHours, 1, 48);
|
||
AutomaticCheckoutSwitch.IsOn = _state.Settings.AutomaticCheckoutEnabled;
|
||
AutomaticCheckoutTimePicker.Time = new TimeSpan(
|
||
Math.Clamp(_state.Settings.AutomaticCheckoutHour, 0, 23),
|
||
Math.Clamp(_state.Settings.AutomaticCheckoutMinute, 0, 59),
|
||
0);
|
||
ShowEmployeeNamesSwitch.IsOn = _state.Settings.ShowEmployeeNamesOnTerminal;
|
||
TelegramEnabledSwitch.IsOn = _state.Settings.TelegramEnabled;
|
||
TelegramTokenBox.Password = _state.Settings.TelegramBotToken;
|
||
VkEnabledSwitch.IsOn = _state.Settings.VkEnabled;
|
||
VkTokenBox.Password = _state.Settings.VkGroupToken;
|
||
MaxEnabledSwitch.IsOn = _state.Settings.MaxEnabled;
|
||
MaxTokenBox.Password = _state.Settings.MaxBotToken;
|
||
StaffServerUrlBox.Text = _state.Settings.StaffServerUrl;
|
||
StaffServerTokenBox.Password = _state.Settings.StaffServerToken;
|
||
StaffServerCertificateBox.Text = _state.Settings.StaffServerCertificateSha256;
|
||
TerminalDeviceIdText.Text = $"Случайный ID терминала: {_state.Settings.TerminalDeviceId}";
|
||
UpdateChecksSwitch.IsOn = _state.Settings.UpdateChecksEnabled;
|
||
UpdateFeedBox.Text = _state.Settings.UpdateFeedUrl;
|
||
UpdateTokenBox.Password = _state.Settings.UpdateAccessToken;
|
||
UpdateSignerThumbprintBox.Text = _state.Settings.UpdateTrustedSignerThumbprint;
|
||
CurrentVersionText.Text = $"Текущая версия: {ForgejoUpdateSource.CurrentVersion.ToString(3)}";
|
||
}
|
||
|
||
public async Task SyncPendingAttendanceAsync()
|
||
{
|
||
if (_attendanceSyncInProgress || _stateLoadFailed) return;
|
||
_attendanceSyncInProgress = true;
|
||
try
|
||
{
|
||
var serviceAvailable = await _ipcClient.IsServiceAvailableAsync();
|
||
if (serviceAvailable)
|
||
{
|
||
var ipcResult = await _ipcClient.TriggerSyncAsync();
|
||
_state = await _store.LoadAsync();
|
||
}
|
||
else
|
||
{
|
||
await _attendanceSync.SyncPendingAsync(_state);
|
||
await _store.SaveAsync(_state);
|
||
}
|
||
|
||
var pendingCount = _state.Attendance.Count(a => !a.ServerSynced);
|
||
var trayState = !_scanner.IsConnected
|
||
? TrayState.NoScanner
|
||
: (_state.Attendance.Any(a => !a.ServerSynced && a.ServerSyncError != null)
|
||
? TrayState.NoServer
|
||
: TrayState.Normal);
|
||
|
||
((App)Application.Current).UpdateTray(trayState, pendingCount);
|
||
}
|
||
catch (InvalidOperationException exception)
|
||
{
|
||
SetStatus(exception.Message, InfoBarSeverity.Warning);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
CrashLogService.Write(ex);
|
||
}
|
||
finally
|
||
{
|
||
_attendanceSyncInProgress = false;
|
||
}
|
||
}
|
||
|
||
private void ApplyStartupSetting()
|
||
{
|
||
try
|
||
{
|
||
StartupService.SetEnabled(_state.Settings.StartWithWindows);
|
||
StartWithWindowsSwitch.IsOn = StartupService.IsEnabled();
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
SetStatus($"Не удалось изменить автозагрузку: {exception.Message}", InfoBarSeverity.Warning);
|
||
}
|
||
}
|
||
|
||
private void RefreshLists()
|
||
{
|
||
var selectedEmployeeId = (EmployeesList.SelectedItem as EmployeeRecord)?.Id;
|
||
_refreshingLists = true;
|
||
try
|
||
{
|
||
var employees = _state.Employees.OrderBy(employee => employee.DisplayName).ToList();
|
||
EmployeesList.ItemsSource = employees;
|
||
EmployeesList.SelectedItem = employees.FirstOrDefault(employee => employee.Id == selectedEmployeeId);
|
||
AttendanceList.ItemsSource = _state.Attendance
|
||
.OrderByDescending(item => item.OccurredAtUtc)
|
||
.Take(100)
|
||
.Select(item => _state.Settings.ShowEmployeeNamesOnTerminal ? item : new AttendanceRecord
|
||
{
|
||
Id = item.Id,
|
||
EmployeeId = item.EmployeeId,
|
||
EmployeeName = MaskName(item.EmployeeName),
|
||
OccurredAtUtc = item.OccurredAtUtc,
|
||
ReaderIndex = item.ReaderIndex,
|
||
EventType = item.EventType,
|
||
NotificationDelivered = item.NotificationDelivered,
|
||
NotificationError = item.NotificationError
|
||
})
|
||
.ToList();
|
||
}
|
||
finally
|
||
{
|
||
_refreshingLists = false;
|
||
}
|
||
}
|
||
|
||
private string TerminalDisplayName(EmployeeRecord employee) =>
|
||
_state.Settings.ShowEmployeeNamesOnTerminal ? employee.DisplayName : MaskName(employee.DisplayName);
|
||
|
||
private void AppendSecurityAudit(string action, string outcome, string detail) =>
|
||
_state.SecurityAudit.Add(new TerminalAuditRecord
|
||
{
|
||
Action = action,
|
||
Actor = _lastAdminActor,
|
||
Outcome = outcome,
|
||
Detail = detail
|
||
});
|
||
|
||
private static string MaskName(string name) => string.Join(' ', name
|
||
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||
.Select(part => part.Length == 1 ? $"{part[0]}…" : $"{part[0]}***"));
|
||
|
||
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());
|
||
|
||
private void EnqueueTask(Func<Task> operation) => App.DispatcherQueue.TryEnqueue(async () =>
|
||
{
|
||
try
|
||
{
|
||
await operation();
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
CrashLogService.Write(exception);
|
||
SetStatus($"Операция не выполнена: {exception.Message}", InfoBarSeverity.Error);
|
||
}
|
||
});
|
||
|
||
private async void SaveStateInBackground()
|
||
{
|
||
try
|
||
{
|
||
await _store.SaveAsync(_state);
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
CrashLogService.Write(exception);
|
||
SetStatus($"Не удалось сохранить данные: {exception.Message}", InfoBarSeverity.Error);
|
||
}
|
||
}
|
||
}
|