Initial commit
commit
b4c15f6f7f
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Local development secrets
|
||||||
|
src/HikvisionAttendanceManager.App/appsettings.Development.json
|
||||||
|
|
||||||
|
.vs/
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
|
||||||
|
Debug/
|
||||||
|
Release/
|
||||||
|
|
||||||
|
*.log
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HikvisionAttendanceManager.App", "src\HikvisionAttendanceManager.App\HikvisionAttendanceManager.App.csproj", "{7E70BA02-5D8E-4CB1-BB72-5FB4B167D120}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{7E70BA02-5D8E-4CB1-BB72-5FB4B167D120}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{7E70BA02-5D8E-4CB1-BB72-5FB4B167D120}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{7E70BA02-5D8E-4CB1-BB72-5FB4B167D120}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{7E70BA02-5D8E-4CB1-BB72-5FB4B167D120}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
using HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
var hrms = new HrmsEmployeeService();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sites = await hrms.GetLocationSitesAsync(CancellationToken.None);
|
||||||
|
Console.WriteLine("Sites: " + sites.Count);
|
||||||
|
if (sites.Count > 0)
|
||||||
|
{
|
||||||
|
var site = sites[0];
|
||||||
|
var deps = await hrms.GetActiveDepartmentsBySiteAsync(site.Id, CancellationToken.None);
|
||||||
|
Console.WriteLine("Departments for site " + site.Id + ": " + deps.Count);
|
||||||
|
var search = await hrms.SearchEmployeesAsync("1", site.Id, CancellationToken.None);
|
||||||
|
Console.WriteLine("Search results: " + search.Count);
|
||||||
|
if (search.Count > 0)
|
||||||
|
Console.WriteLine("First: " + search[0].SerialNumber + " - " + search[0].Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("FAIL: " + ex.Message);
|
||||||
|
if (ex.InnerException is not null) Console.WriteLine(ex.InnerException.Message);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
<Application x:Class="HikvisionAttendanceManager.App.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
StartupUri="MainWindow.xaml">
|
||||||
|
<Application.Resources>
|
||||||
|
<Color x:Key="Blue">#2563EB</Color>
|
||||||
|
<Color x:Key="BlueDark">#1D4ED8</Color>
|
||||||
|
<Color x:Key="Canvas">#F4F7FB</Color>
|
||||||
|
<Color x:Key="Border">#E6EBF3</Color>
|
||||||
|
<SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource Blue}"/>
|
||||||
|
<SolidColorBrush x:Key="CanvasBrush" Color="{StaticResource Canvas}"/>
|
||||||
|
<SolidColorBrush x:Key="BorderBrush" Color="{StaticResource Border}"/>
|
||||||
|
<Style TargetType="Button">
|
||||||
|
<Setter Property="Padding" Value="15,8"/>
|
||||||
|
<Setter Property="Margin" Value="0"/>
|
||||||
|
<Setter Property="FontSize" Value="13"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Background" Value="White"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="7" Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="PrimaryButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
|
||||||
|
<Setter Property="Background" Value="{StaticResource PrimaryBrush}"/>
|
||||||
|
<Setter Property="Foreground" Value="White"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource PrimaryBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="SidebarButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Left"/>
|
||||||
|
<Setter Property="Padding" Value="14,10"/>
|
||||||
|
<Setter Property="Foreground" Value="#516079"/>
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Margin" Value="0,2"/>
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="CardStyle" TargetType="Border">
|
||||||
|
<Setter Property="Background" Value="White"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
<Setter Property="CornerRadius" Value="10"/>
|
||||||
|
<Setter Property="Padding" Value="20"/>
|
||||||
|
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="CardCaption" TargetType="TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="#718096"/>
|
||||||
|
<Setter Property="FontSize" Value="11"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="CardValue" TargetType="TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="#17233B"/>
|
||||||
|
<Setter Property="FontSize" Value="27"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
<Setter Property="Margin" Value="0,8,0,5"/>
|
||||||
|
</Style>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
using System.Windows;
|
||||||
|
using HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App;
|
||||||
|
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
public App()
|
||||||
|
{
|
||||||
|
AppLogger.Initialize();
|
||||||
|
AppLogger.Info("[STARTUP] Application starting");
|
||||||
|
AppLogger.Info("[STARTUP] Configuration loaded");
|
||||||
|
DispatcherUnhandledException += (_, args) => AppLogger.Error("Unhandled UI exception.", args.Exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<UserControl x:Class="HikvisionAttendanceManager.App.AttendanceSyncView"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="Attendance Sync" FontSize="25" FontWeight="SemiBold" Foreground="#17233B"/>
|
||||||
|
<TextBlock Text="Fetch ACS events from a Hikvision device and insert them into attendance_log." Margin="0,6,0,22" Foreground="#66758C"/>
|
||||||
|
<Border Style="{StaticResource CardStyle}" Margin="0,0,0,14">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="DEVICE & DATE RANGE" Style="{StaticResource CardCaption}"/>
|
||||||
|
<Grid Margin="0,12,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="180"/>
|
||||||
|
<ColumnDefinition Width="180"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Column="0" Margin="0,0,10,0">
|
||||||
|
<TextBlock Text="Hikvision Machine" Foreground="#475569"/>
|
||||||
|
<ComboBox x:Name="DeviceCombo" DisplayMemberPath="Name"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1" Margin="0,0,10,0">
|
||||||
|
<TextBlock Text="From" Foreground="#475569"/>
|
||||||
|
<DatePicker x:Name="FromDate"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2">
|
||||||
|
<TextBlock Text="To" Foreground="#475569"/>
|
||||||
|
<DatePicker x:Name="ToDate"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
<CheckBox x:Name="UseCursorCheck" Content="Respect last_sync_date cursor from HRMS" IsChecked="True" Margin="0,14,0,0"/>
|
||||||
|
<Button Content="Start Attendance Sync" Style="{StaticResource PrimaryButton}" HorizontalAlignment="Left" Margin="0,14,0,0" Click="StartSync_Click"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
<Border x:Name="ProgressCard" Style="{StaticResource CardStyle}" Visibility="Collapsed">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="Sync Progress" FontWeight="SemiBold"/>
|
||||||
|
<ProgressBar x:Name="ProgressBar" Height="12" Margin="0,12,0,6"/>
|
||||||
|
<TextBlock x:Name="ProgressText" Foreground="#475569"/>
|
||||||
|
<TextBlock x:Name="CounterText" Margin="0,8,0,0" Foreground="#475569"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</UserControl>
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using HikvisionAttendanceManager.App.Models;
|
||||||
|
using HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App;
|
||||||
|
|
||||||
|
public partial class AttendanceSyncView : UserControl
|
||||||
|
{
|
||||||
|
private readonly DeviceService _devices = new();
|
||||||
|
private readonly AttendanceSyncService _service;
|
||||||
|
private bool _initialized;
|
||||||
|
|
||||||
|
public AttendanceSyncView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_service = new AttendanceSyncService(new HikvisionIsapiClient(), new AttendanceLogRepository(), new AttendanceMachineRepository(), new OperationHistoryService());
|
||||||
|
FromDate.SelectedDate = DateTime.Today.AddDays(-1);
|
||||||
|
ToDate.SelectedDate = DateTime.Today;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
if (_initialized) return;
|
||||||
|
_initialized = true;
|
||||||
|
DeviceCombo.ItemsSource = await _devices.LoadUnifiedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void StartSync_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DeviceCombo.SelectedItem is not Device device) { MessageBox.Show("Select a device."); return; }
|
||||||
|
if (FromDate.SelectedDate is null || ToDate.SelectedDate is null) { MessageBox.Show("Select a date range."); return; }
|
||||||
|
|
||||||
|
var from = FromDate.SelectedDate.Value.Date;
|
||||||
|
var to = ToDate.SelectedDate.Value.Date.AddDays(1).AddSeconds(-1);
|
||||||
|
if (to < from) { MessageBox.Show("End date must be on or after start date."); return; }
|
||||||
|
|
||||||
|
ProgressCard.Visibility = Visibility.Visible;
|
||||||
|
ProgressBar.IsIndeterminate = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var progress = new Progress<AttendanceSyncProgress>(p =>
|
||||||
|
{
|
||||||
|
ProgressBar.IsIndeterminate = false;
|
||||||
|
ProgressBar.Maximum = Math.Max(1, p.Fetched);
|
||||||
|
ProgressBar.Value = p.Inserted + p.Skipped + p.Failed;
|
||||||
|
ProgressText.Text = p.Message;
|
||||||
|
CounterText.Text = $"Fetched: {p.Fetched} · Inserted: {p.Inserted} · Skipped: {p.Skipped} · Failed: {p.Failed}";
|
||||||
|
});
|
||||||
|
var entry = await _service.SyncAsync(device, from, to, UseCursorCheck.IsChecked == true, progress, CancellationToken.None);
|
||||||
|
ProgressText.Text = $"Completed — {entry.Status}";
|
||||||
|
CounterText.Text = $"Fetched: {entry.Total} · Inserted: {entry.Success} · Skipped: {entry.Skipped} · Failed: {entry.Failed}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ProgressText.Text = "Sync failed: " + ex.Message;
|
||||||
|
MessageBox.Show(ex.Message, "Attendance Sync", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ProgressBar.IsIndeterminate = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
<UserControl x:Class="HikvisionAttendanceManager.App.DbToDeviceView"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="DB → Device" FontSize="25" FontWeight="SemiBold" Foreground="#17233B"/>
|
||||||
|
<TextBlock Text="Transfer users registered to a source machine in DB onto a different target Hikvision device (JPEG face templates only)." Margin="0,6,0,22" Foreground="#66758C"/>
|
||||||
|
<Border Style="{StaticResource CardStyle}" Margin="0,0,0,14">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="MACHINES" Style="{StaticResource CardCaption}"/>
|
||||||
|
<Grid Margin="0,12,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Column="0" Margin="0,0,10,0">
|
||||||
|
<TextBlock Text="Source Machine (DB)" Foreground="#475569"/>
|
||||||
|
<ComboBox x:Name="SourceCombo" DisplayMemberPath="Name" SelectionChanged="SourceChanged"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1">
|
||||||
|
<TextBlock Text="Target Machine" Foreground="#475569"/>
|
||||||
|
<ComboBox x:Name="TargetCombo" DisplayMemberPath="Name"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,14,0,0">
|
||||||
|
<Button Content="Load Users from Source DB" Click="LoadUsers_Click"/>
|
||||||
|
<Button Content="Select All" Margin="8,0,0,0" Click="SelectAll_Click"/>
|
||||||
|
<Button Content="Clear Selection" Margin="8,0,0,0" Click="ClearSelection_Click"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
<Border Style="{StaticResource CardStyle}" Margin="0,0,0,14">
|
||||||
|
<DataGrid x:Name="UsersGrid" AutoGenerateColumns="False" CanUserAddRows="False" MaxHeight="280">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridCheckBoxColumn Header="Select" Binding="{Binding IsSelected, UpdateSourceTrigger=PropertyChanged}" Width="70"/>
|
||||||
|
<DataGridTextColumn Header="Serial" Binding="{Binding SerialNumber}" Width="100"/>
|
||||||
|
<DataGridTextColumn Header="Name" Binding="{Binding EmployeeName}" Width="*"/>
|
||||||
|
<DataGridCheckBoxColumn Header="Face in DB" Binding="{Binding HasFaceTemplate, Mode=OneWay}" Width="90" IsReadOnly="True"/>
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</Border>
|
||||||
|
<Button Content="Transfer Selected to Target" Style="{StaticResource PrimaryButton}" HorizontalAlignment="Left" Click="Transfer_Click"/>
|
||||||
|
<Border x:Name="ProgressCard" Style="{StaticResource CardStyle}" Visibility="Collapsed" Margin="0,14,0,0">
|
||||||
|
<StackPanel>
|
||||||
|
<ProgressBar x:Name="ProgressBar" Height="12" Margin="0,0,0,8"/>
|
||||||
|
<TextBlock x:Name="ProgressText" Foreground="#475569"/>
|
||||||
|
<TextBlock x:Name="CounterText" Margin="0,8,0,0" Foreground="#475569"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</UserControl>
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using HikvisionAttendanceManager.App.Models;
|
||||||
|
using HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App;
|
||||||
|
|
||||||
|
public partial class DbToDeviceView : UserControl
|
||||||
|
{
|
||||||
|
private readonly DeviceService _devices = new();
|
||||||
|
private readonly AttendanceMachineUserRepository _machineUsers = new();
|
||||||
|
private readonly AttendanceMachineFaceTemplateRepository _faceTemplates = new();
|
||||||
|
private readonly TemplateSyncService _service;
|
||||||
|
private readonly ObservableCollection<SelectableMachineUser> _rows = [];
|
||||||
|
private bool _initialized;
|
||||||
|
|
||||||
|
public DbToDeviceView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_service = new TemplateSyncService(new HikvisionIsapiClient(), _machineUsers, _faceTemplates, new OperationHistoryService());
|
||||||
|
UsersGrid.ItemsSource = _rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
if (_initialized) return;
|
||||||
|
_initialized = true;
|
||||||
|
var devices = await _devices.LoadUnifiedAsync();
|
||||||
|
SourceCombo.ItemsSource = devices;
|
||||||
|
TargetCombo.ItemsSource = devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SourceChanged(object sender, SelectionChangedEventArgs e) => _rows.Clear();
|
||||||
|
|
||||||
|
private async void LoadUsers_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (SourceCombo.SelectedItem is not Device source) { MessageBox.Show("Select a source machine."); return; }
|
||||||
|
_rows.Clear();
|
||||||
|
var users = await _machineUsers.GetActiveByMachineAsync(source.MachineId, CancellationToken.None);
|
||||||
|
foreach (var user in users)
|
||||||
|
{
|
||||||
|
var template = await _faceTemplates.TryGetActiveAsync(user.SerialNumber, CancellationToken.None);
|
||||||
|
var hasFace = template?.Template is { Length: > 0 } && HikvisionIsapiClient.IsUploadableFaceTemplate(template.Template);
|
||||||
|
_rows.Add(new SelectableMachineUser(user.SerialNumber, user.EmployeeName, hasFace));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectAll_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
foreach (var row in _rows) row.IsSelected = true;
|
||||||
|
UsersGrid.Items.Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearSelection_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
foreach (var row in _rows) row.IsSelected = false;
|
||||||
|
UsersGrid.Items.Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void Transfer_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (SourceCombo.SelectedItem is not Device source || TargetCombo.SelectedItem is not Device target)
|
||||||
|
{
|
||||||
|
MessageBox.Show("Select source and target machines.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (string.Equals(source.MachineId, target.MachineId, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
string.Equals(source.IpAddress, target.IpAddress, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
MessageBox.Show("Source and target must be different devices.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var selected = _rows.Where(r => r.IsSelected).Select(r => r.SerialNumber).ToList();
|
||||||
|
if (selected.Count == 0) { MessageBox.Show("Select at least one user."); return; }
|
||||||
|
|
||||||
|
ProgressCard.Visibility = Visibility.Visible;
|
||||||
|
var progress = new Progress<UserSyncProgress>(p =>
|
||||||
|
{
|
||||||
|
ProgressBar.Maximum = Math.Max(1, p.Total);
|
||||||
|
ProgressBar.Value = p.Completed;
|
||||||
|
ProgressText.Text = $"{p.Completed}/{p.Total} · {p.CurrentEmployee} · {p.CurrentOperation}";
|
||||||
|
CounterText.Text = $"Users: {p.Created} · Faces: {p.FacesUploaded} · Skipped: {p.Skipped} · Failed: {p.Failed}";
|
||||||
|
});
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entry = await _service.DbToDeviceAsync(source, target, selected, progress, CancellationToken.None);
|
||||||
|
ProgressText.Text = $"Completed — {entry.Status}";
|
||||||
|
CounterText.Text = entry.Context;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show(ex.Message, "DB → Device", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
<UserControl x:Class="HikvisionAttendanceManager.App.DepartmentalUserSyncView"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="Departmental User Sync" FontSize="25" FontWeight="SemiBold" Foreground="#17233B"/>
|
||||||
|
<TextBlock Text="Bulk create and enroll active employees from selected departments onto a target Hikvision device." Margin="0,6,0,22" Foreground="#66758C"/>
|
||||||
|
<Border Style="{StaticResource CardStyle}" Margin="0,0,0,14">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="TARGET & FILTERS" Style="{StaticResource CardCaption}"/>
|
||||||
|
<Grid Margin="0,12,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="220"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Column="0" Margin="0,0,10,0">
|
||||||
|
<TextBlock Text="Target Hikvision Device" Foreground="#475569"/>
|
||||||
|
<ComboBox x:Name="DeviceCombo" DisplayMemberPath="Name"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1">
|
||||||
|
<TextBlock Text="Location Site" Foreground="#475569"/>
|
||||||
|
<ComboBox x:Name="SiteCombo" DisplayMemberPath="DisplayName" SelectionChanged="SiteChanged"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Text="Department" Margin="0,16,0,8" Foreground="#475569" FontWeight="SemiBold"/>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<RadioButton x:Name="SelectedDepartmentsMode" Content="Selected Departments" Checked="ModeChanged" Margin="0,0,20,0"/>
|
||||||
|
<RadioButton x:Name="AllDepartmentsMode" Content="All Active Departments" Checked="ModeChanged"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Border x:Name="DepartmentSelectorPanel" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="8" Padding="12" Margin="0,12,0,0" Background="#FAFBFD">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="Select one or more departments" Foreground="#66758C" FontSize="12" Margin="0,0,0,8"/>
|
||||||
|
<ItemsControl x:Name="DepartmentItems">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<CheckBox Content="{Binding Name}" IsChecked="{Binding IsSelected, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<TextBlock x:Name="DepartmentStatusText" Margin="0,10,0,0" Foreground="#66758C" TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
<Button Content="Load Employees" Style="{StaticResource PrimaryButton}" HorizontalAlignment="Left" Margin="0,14,0,0" Click="LoadEmployees_Click"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
<Border Style="{StaticResource CardStyle}" Margin="0,0,0,14">
|
||||||
|
<StackPanel>
|
||||||
|
<DockPanel Margin="0,0,0,12">
|
||||||
|
<TextBlock Text="EMPLOYEES" Style="{StaticResource CardCaption}" DockPanel.Dock="Left"/>
|
||||||
|
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
|
||||||
|
<Button Content="Select All" Click="SelectAll_Click"/>
|
||||||
|
<Button Content="Clear Selection" Margin="8,0,0,0" Click="ClearSelection_Click"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DockPanel>
|
||||||
|
<TextBlock x:Name="EmployeeStatusText" Foreground="#66758C" Margin="0,0,0,10" TextWrapping="Wrap"/>
|
||||||
|
<DataGrid x:Name="EmployeesGrid" AutoGenerateColumns="False" CanUserAddRows="False" MaxHeight="320">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridCheckBoxColumn Header="Select" Binding="{Binding IsSelected, UpdateSourceTrigger=PropertyChanged}" Width="70"/>
|
||||||
|
<DataGridTextColumn Header="Serial" Binding="{Binding SerialNumber, Mode=OneWay}" Width="90" IsReadOnly="True"/>
|
||||||
|
<DataGridTextColumn Header="Name" Binding="{Binding Name, Mode=OneWay}" Width="*"/>
|
||||||
|
<DataGridTextColumn Header="Department" Binding="{Binding Department, Mode=OneWay}" Width="*"/>
|
||||||
|
<DataGridTextColumn Header="Site" Binding="{Binding LocationSiteDisplay, Mode=OneWay}" Width="90" IsReadOnly="True"/>
|
||||||
|
<DataGridTextColumn Header="Active" Binding="{Binding ActiveDisplay, Mode=OneWay}" Width="60" IsReadOnly="True"/>
|
||||||
|
<DataGridTextColumn Header="Existing" Binding="{Binding ExistingOnDeviceDisplay, Mode=OneWay}" Width="70" IsReadOnly="True"/>
|
||||||
|
<DataGridTextColumn Header="Photo" Binding="{Binding PhotoStatus, Mode=OneWay}" Width="110" IsReadOnly="True"/>
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
<Button Content="Create & Enroll Selected on Device" Style="{StaticResource PrimaryButton}" HorizontalAlignment="Left" Click="StartSync_Click"/>
|
||||||
|
<Border x:Name="ProgressCard" Style="{StaticResource CardStyle}" Visibility="Collapsed" Margin="0,14,0,0">
|
||||||
|
<StackPanel>
|
||||||
|
<ProgressBar x:Name="ProgressBar" Height="12" Margin="0,0,0,8"/>
|
||||||
|
<TextBlock x:Name="ProgressText" Foreground="#475569"/>
|
||||||
|
<TextBlock x:Name="CounterText" Margin="0,8,0,0" Foreground="#475569"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
<Border x:Name="ResultsCard" Style="{StaticResource CardStyle}" Visibility="Collapsed" Margin="0,14,0,0">
|
||||||
|
<DataGrid x:Name="ResultsGrid" AutoGenerateColumns="False" IsReadOnly="True" CanUserAddRows="False" MaxHeight="220">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="Employee" Binding="{Binding EmployeeNumber}" Width="100"/>
|
||||||
|
<DataGridTextColumn Header="Name" Binding="{Binding EmployeeName}" Width="*"/>
|
||||||
|
<DataGridTextColumn Header="Result" Binding="{Binding OverallResult}" Width="90"/>
|
||||||
|
<DataGridTextColumn Header="Reason" Binding="{Binding Reason}" Width="2*"/>
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</UserControl>
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using HikvisionAttendanceManager.App.Models;
|
||||||
|
using HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App;
|
||||||
|
|
||||||
|
public partial class DepartmentalUserSyncView : UserControl
|
||||||
|
{
|
||||||
|
private readonly DeviceService _devices = new();
|
||||||
|
private readonly HrmsEmployeeService _hrms = new();
|
||||||
|
private readonly AttendanceMachineUserRepository _machineUsers = new();
|
||||||
|
private readonly DepartmentalUserSyncService _service;
|
||||||
|
private readonly ObservableCollection<SelectableEmployee> _employees = [];
|
||||||
|
private readonly ObservableCollection<SelectableDepartment> _departments = [];
|
||||||
|
private bool _initialized;
|
||||||
|
|
||||||
|
public DepartmentalUserSyncView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_service = new DepartmentalUserSyncService(
|
||||||
|
new UserSyncEngine(new HikvisionIsapiClient(), new EmployeePhotoService()),
|
||||||
|
new OperationHistoryService());
|
||||||
|
EmployeesGrid.ItemsSource = _employees;
|
||||||
|
DepartmentItems.ItemsSource = _departments;
|
||||||
|
SelectedDepartmentsMode.IsChecked = true;
|
||||||
|
ApplyDepartmentMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
if (_initialized) return;
|
||||||
|
_initialized = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DeviceCombo.ItemsSource = await _devices.LoadUnifiedAsync();
|
||||||
|
SiteCombo.ItemsSource = await _hrms.GetLocationSitesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (HrmsDataException ex)
|
||||||
|
{
|
||||||
|
DepartmentStatusText.Text = ex.Message;
|
||||||
|
}
|
||||||
|
ApplyDepartmentMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ModeChanged(object sender, RoutedEventArgs e) => ApplyDepartmentMode();
|
||||||
|
|
||||||
|
private void ApplyDepartmentMode()
|
||||||
|
{
|
||||||
|
if (DepartmentSelectorPanel is null || SelectedDepartmentsMode is null) return;
|
||||||
|
var selectedMode = SelectedDepartmentsMode.IsChecked == true;
|
||||||
|
DepartmentSelectorPanel.Visibility = selectedMode ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void SiteChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
_employees.Clear();
|
||||||
|
EmployeeStatusText.Text = "";
|
||||||
|
_departments.Clear();
|
||||||
|
if (SiteCombo.SelectedItem is not LocationSite site)
|
||||||
|
{
|
||||||
|
DepartmentStatusText.Text = "Select a location site to load departments.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DepartmentStatusText.Text = "Loading departments…";
|
||||||
|
var departments = await _hrms.GetActiveDepartmentsBySiteAsync(site.Id, CancellationToken.None);
|
||||||
|
foreach (var department in departments)
|
||||||
|
_departments.Add(new SelectableDepartment(department));
|
||||||
|
DepartmentStatusText.Text = departments.Count == 0
|
||||||
|
? "No active departments found for this location site."
|
||||||
|
: $"{departments.Count} active department(s) available. Select one or more.";
|
||||||
|
}
|
||||||
|
catch (HrmsDataException ex)
|
||||||
|
{
|
||||||
|
DepartmentStatusText.Text = ex.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void LoadEmployees_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (SiteCombo.SelectedItem is not LocationSite site)
|
||||||
|
{
|
||||||
|
MessageBox.Show("Select a location site.", "Departmental User Sync", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_employees.Clear();
|
||||||
|
EmployeeStatusText.Text = "Loading employees…";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IReadOnlyList<HrmsEmployee> employees;
|
||||||
|
if (AllDepartmentsMode.IsChecked == true)
|
||||||
|
{
|
||||||
|
employees = await _hrms.GetEmployeesForAllActiveDepartmentsAsync(site.Id, CancellationToken.None);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var deptIds = _departments.Where(d => d.IsSelected).Select(d => d.Department.Id).ToList();
|
||||||
|
if (deptIds.Count == 0)
|
||||||
|
{
|
||||||
|
EmployeeStatusText.Text = "Select at least one department.";
|
||||||
|
MessageBox.Show("Select at least one department.", "Departmental User Sync", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
employees = await _hrms.GetEmployeesForDepartmentsAsync(deptIds, site.Id, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
var device = DeviceCombo.SelectedItem as Device;
|
||||||
|
HashSet<string>? existing = null;
|
||||||
|
if (device is not null && !string.IsNullOrWhiteSpace(device.MachineId))
|
||||||
|
{
|
||||||
|
var machineUsers = await _machineUsers.GetActiveByMachineAsync(device.MachineId, CancellationToken.None);
|
||||||
|
existing = machineUsers.Select(u => u.SerialNumber).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var employee in employees)
|
||||||
|
{
|
||||||
|
var onDevice = existing?.Contains(employee.SerialNumber) == true;
|
||||||
|
_employees.Add(new SelectableEmployee(employee, site.DisplayName, onDevice));
|
||||||
|
}
|
||||||
|
|
||||||
|
EmployeeStatusText.Text = employees.Count == 0
|
||||||
|
? "No active employees found for the selected filters."
|
||||||
|
: $"{employees.Count} employee(s) loaded. Select employees to create and enroll on the target device.";
|
||||||
|
}
|
||||||
|
catch (HrmsDataException ex)
|
||||||
|
{
|
||||||
|
EmployeeStatusText.Text = ex.Message;
|
||||||
|
MessageBox.Show(ex.Message, "Departmental User Sync", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectAll_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
foreach (var row in _employees) row.IsSelected = true;
|
||||||
|
EmployeesGrid.Items.Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearSelection_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
foreach (var row in _employees) row.IsSelected = false;
|
||||||
|
EmployeesGrid.Items.Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void StartSync_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DeviceCombo.SelectedItem is not Device device) { MessageBox.Show("Select a target device."); return; }
|
||||||
|
var selected = _employees.Where(e => e.IsSelected).Select(e => e.Employee).ToList();
|
||||||
|
if (selected.Count == 0) { MessageBox.Show("Select at least one employee."); return; }
|
||||||
|
|
||||||
|
ProgressCard.Visibility = Visibility.Visible;
|
||||||
|
ResultsCard.Visibility = Visibility.Collapsed;
|
||||||
|
var progress = new Progress<UserSyncProgress>(p =>
|
||||||
|
{
|
||||||
|
ProgressBar.Maximum = Math.Max(1, p.Total);
|
||||||
|
ProgressBar.Value = p.Completed;
|
||||||
|
ProgressText.Text = $"{p.Completed}/{p.Total} · {p.CurrentEmployee} · {p.CurrentOperation}";
|
||||||
|
CounterText.Text = $"Created: {p.Created} · Faces: {p.FacesUploaded} · Skipped: {p.Skipped} · Failed: {p.Failed}";
|
||||||
|
});
|
||||||
|
var siteName = (SiteCombo.SelectedItem as LocationSite)?.DisplayName ?? "";
|
||||||
|
var (_, results) = await _service.SyncAsync(device, selected, $"{selected.Count} employees · {siteName}", progress, CancellationToken.None);
|
||||||
|
ResultsGrid.ItemsSource = results;
|
||||||
|
ResultsCard.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<UserControl x:Class="HikvisionAttendanceManager.App.DeviceToDbView"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="Device → DB" FontSize="25" FontWeight="SemiBold" Foreground="#17233B"/>
|
||||||
|
<TextBlock Text="Read Hikvision users and face templates from a device and save them to attendance_machine_user and attendance_machine_face_templates." Margin="0,6,0,22" Foreground="#66758C"/>
|
||||||
|
<Border Style="{StaticResource CardStyle}" Margin="0,0,0,14">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="SOURCE DEVICE" Style="{StaticResource CardCaption}"/>
|
||||||
|
<ComboBox x:Name="DeviceCombo" DisplayMemberPath="Name" MinWidth="320" HorizontalAlignment="Left" Margin="0,12,0,0"/>
|
||||||
|
<Button Content="Sync Device → DB" Style="{StaticResource PrimaryButton}" HorizontalAlignment="Left" Margin="0,14,0,0" Click="StartSync_Click"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
<Border x:Name="ProgressCard" Style="{StaticResource CardStyle}" Visibility="Collapsed">
|
||||||
|
<StackPanel>
|
||||||
|
<ProgressBar x:Name="ProgressBar" Height="12" Margin="0,0,0,8"/>
|
||||||
|
<TextBlock x:Name="ProgressText" Foreground="#475569"/>
|
||||||
|
<TextBlock x:Name="CounterText" Margin="0,8,0,0" Foreground="#475569"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</UserControl>
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using HikvisionAttendanceManager.App.Models;
|
||||||
|
using HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App;
|
||||||
|
|
||||||
|
public partial class DeviceToDbView : UserControl
|
||||||
|
{
|
||||||
|
private readonly DeviceService _devices = new();
|
||||||
|
private readonly TemplateSyncService _service;
|
||||||
|
private bool _initialized;
|
||||||
|
|
||||||
|
public DeviceToDbView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_service = new TemplateSyncService(new HikvisionIsapiClient(), new AttendanceMachineUserRepository(), new AttendanceMachineFaceTemplateRepository(), new OperationHistoryService());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
if (_initialized) return;
|
||||||
|
_initialized = true;
|
||||||
|
DeviceCombo.ItemsSource = await _devices.LoadUnifiedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void StartSync_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DeviceCombo.SelectedItem is not Device device) { MessageBox.Show("Select a device."); return; }
|
||||||
|
ProgressCard.Visibility = Visibility.Visible;
|
||||||
|
var progress = new Progress<UserSyncProgress>(p =>
|
||||||
|
{
|
||||||
|
ProgressBar.Maximum = Math.Max(1, p.Total);
|
||||||
|
ProgressBar.Value = p.Completed;
|
||||||
|
ProgressText.Text = $"{p.Completed}/{p.Total} · {p.CurrentEmployee} · {p.CurrentOperation}";
|
||||||
|
CounterText.Text = $"Saved: {p.Created} · Skipped: {p.Skipped} · Failed: {p.Failed}";
|
||||||
|
});
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entry = await _service.DeviceToDbAsync(device, progress, CancellationToken.None);
|
||||||
|
ProgressText.Text = $"Completed — {entry.Status}";
|
||||||
|
CounterText.Text = $"Total: {entry.Total} · Success: {entry.Success} · Skipped: {entry.Skipped} · Failed: {entry.Failed}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show(ex.Message, "Device → DB", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net10.0-windows</TargetFramework>
|
||||||
|
<UseWPF>true</UseWPF>
|
||||||
|
<PlatformTarget>x86</PlatformTarget>
|
||||||
|
<AssemblyName>HikvisionAttendanceManager</AssemblyName>
|
||||||
|
<RootNamespace>HikvisionAttendanceManager.App</RootNamespace>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.11" />
|
||||||
|
<PackageReference Include="MySql.Data" Version="9.6.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="appsettings*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
<Window x:Class="HikvisionAttendanceManager.App.MainWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:local="clr-namespace:HikvisionAttendanceManager.App"
|
||||||
|
Title="Hikvision Attendance Manager" Height="780" Width="1280" MinHeight="620" MinWidth="1020"
|
||||||
|
WindowStartupLocation="CenterScreen" Background="{StaticResource CanvasBrush}">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="205"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Border Grid.Column="0" Background="White" BorderBrush="{StaticResource BorderBrush}" BorderThickness="0,0,1,0">
|
||||||
|
<DockPanel Margin="12">
|
||||||
|
<StackPanel DockPanel.Dock="Top">
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="7,12,7,26">
|
||||||
|
<Border Width="31" Height="35" CornerRadius="8" Background="#EAF1FF">
|
||||||
|
<TextBlock Text="⌂" Foreground="{StaticResource PrimaryBrush}" FontWeight="Bold" FontSize="18" VerticalAlignment="Center" HorizontalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<StackPanel Margin="10,0,0,0" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="Hikvision" FontWeight="Bold" FontSize="16" Foreground="#16213A"/>
|
||||||
|
<TextBlock Text="Attendance Manager" FontSize="10" Foreground="#77849A"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
<Button x:Name="DashboardNav" Content="⌂ Dashboard" Style="{StaticResource SidebarButton}" Background="#EAF1FF" Foreground="#1D4ED8" Click="Navigate_Click" Tag="Dashboard"/>
|
||||||
|
<Button Content="▣ Devices" Style="{StaticResource SidebarButton}" Click="Navigate_Click" Tag="Devices"/>
|
||||||
|
<Button Content="⇄ Attendance Sync" Style="{StaticResource SidebarButton}" Click="Navigate_Click" Tag="Attendance Sync"/>
|
||||||
|
<Button Content="♙ User Management" Style="{StaticResource SidebarButton}" Click="Navigate_Click" Tag="User Management"/>
|
||||||
|
<TextBlock Text="TEMPLATE SYNCING" Margin="14,16,0,4" FontSize="10" FontWeight="SemiBold" Foreground="#94A3B8"/>
|
||||||
|
<Button Content=" ↓ Device → DB" Style="{StaticResource SidebarButton}" Click="Navigate_Click" Tag="Device → DB"/>
|
||||||
|
<Button Content=" ↑ DB → Device" Style="{StaticResource SidebarButton}" Click="Navigate_Click" Tag="DB → Device"/>
|
||||||
|
<Button Content=" ▤ Departmental User Sync" Style="{StaticResource SidebarButton}" Click="Navigate_Click" Tag="Departmental User Sync"/>
|
||||||
|
<Button Content="◷ Sync History" Style="{StaticResource SidebarButton}" Click="Navigate_Click" Tag="Sync History"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Button DockPanel.Dock="Bottom" Content="⚙ Settings" Style="{StaticResource SidebarButton}" Click="Navigate_Click" Tag="Settings"/>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Grid Grid.Column="1" Margin="27,20">
|
||||||
|
<Grid x:Name="DashboardPage">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="330"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Row="0">
|
||||||
|
<TextBlock Text="Welcome, Operator 👋" FontSize="21" FontWeight="SemiBold" Foreground="#17233B"/>
|
||||||
|
<TextBlock Text="Manage your Hikvision devices and users easily." Margin="0,4,0,0" Foreground="#66758C" FontSize="12"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Row="0" Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||||
|
<Border Background="White" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="18" Padding="10,5" Margin="0,0,10,0"><TextBlock Text="● Operator" Foreground="#475569" FontSize="11"/></Border>
|
||||||
|
<Button x:Name="RefreshDashboardButton" Content="↻ Refresh" Click="RefreshDashboard_Click"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock x:Name="DashboardErrorText" Grid.Row="0" Grid.ColumnSpan="2" Margin="0,52,0,0" Foreground="#DC2626" FontSize="12" Visibility="Collapsed" TextWrapping="Wrap"/>
|
||||||
|
<UniformGrid Grid.Row="1" Grid.ColumnSpan="2" Columns="4" Margin="0,28,0,20">
|
||||||
|
<Border Style="{StaticResource CardStyle}" Background="#F5FAFF" Margin="0,0,12,0"><StackPanel><TextBlock Text="TOTAL DEVICES" Style="{StaticResource CardCaption}"/><TextBlock Text="{Binding Dashboard.TotalDevicesDisplay, Mode=OneWay}" Style="{StaticResource CardValue}"/><TextBlock Text="Active Hikvision devices" Foreground="#748299" FontSize="11"/></StackPanel></Border>
|
||||||
|
<Border Style="{StaticResource CardStyle}" Background="#F2FBF5" Margin="0,0,12,0"><StackPanel><TextBlock Text="ONLINE DEVICES" Style="{StaticResource CardCaption}"/><TextBlock Text="{Binding Dashboard.OnlineDevicesDisplay, Mode=OneWay}" Style="{StaticResource CardValue}" Foreground="#15803D"/><TextBlock Text="Connected" Foreground="#15803D" FontSize="11"/></StackPanel></Border>
|
||||||
|
<Border Style="{StaticResource CardStyle}" Background="#FFF6F6" Margin="0,0,12,0"><StackPanel><TextBlock Text="OFFLINE DEVICES" Style="{StaticResource CardCaption}"/><TextBlock Text="{Binding Dashboard.OfflineDevicesDisplay, Mode=OneWay}" Style="{StaticResource CardValue}" Foreground="#DC2626"/><TextBlock Text="Not Connected" Foreground="#DC2626" FontSize="11"/></StackPanel></Border>
|
||||||
|
<Border Style="{StaticResource CardStyle}" Background="#F7F5FF"><StackPanel><TextBlock Text="TOTAL USERS ON DEVICES" Style="{StaticResource CardCaption}"/><TextBlock Text="{Binding Dashboard.TotalUsersDisplay, Mode=OneWay}" Style="{StaticResource CardValue}"/><TextBlock Text="Across all devices" Foreground="#748299" FontSize="11"/></StackPanel></Border>
|
||||||
|
</UniformGrid>
|
||||||
|
<Grid Grid.Row="2" Grid.ColumnSpan="2">
|
||||||
|
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="300"/></Grid.ColumnDefinitions>
|
||||||
|
<Border Style="{StaticResource CardStyle}" Margin="0,0,18,0">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="Recent Activity" FontWeight="SemiBold" FontSize="16" Foreground="#1D293D"/>
|
||||||
|
<TextBlock x:Name="ActivityText" Text="No activity yet. Add a device to get started." Margin="0,22,0,0" Foreground="#748299"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
<Border Grid.Column="1" Style="{StaticResource CardStyle}">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="Device Status" FontWeight="SemiBold" FontSize="16" Foreground="#1D293D"/>
|
||||||
|
<TextBlock Text="{Binding Dashboard.DeviceStatusSummary, Mode=OneWay}" Margin="0,22,0,8" Foreground="#748299"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding Devices}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<TextBlock Text="{Binding DashboardListLine}" Margin="0,0,0,6" Foreground="#475569" FontSize="12"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<TextBlock x:Name="LastHrmsSyncText" Margin="0,16,0,0" Foreground="#748299" FontSize="11"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid x:Name="DevicesPage" Visibility="Collapsed">
|
||||||
|
<Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions>
|
||||||
|
<DockPanel>
|
||||||
|
<StackPanel DockPanel.Dock="Left"><TextBlock Text="Devices" FontSize="25" FontWeight="SemiBold" Foreground="#17233B"/><TextBlock Text="Manage Hikvision terminals and test their connectivity." Margin="0,6,0,0" Foreground="#66758C"/></StackPanel>
|
||||||
|
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<Button Content="Refresh" Margin="0,0,8,0" Click="RefreshDevices_Click"/>
|
||||||
|
<Button Content="+ Add Device" Style="{StaticResource PrimaryButton}" Click="AddDevice_Click"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DockPanel>
|
||||||
|
<Border Grid.Row="1" x:Name="DeviceEditor" Visibility="Collapsed" Background="White" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="10" Margin="0,24,0,16" Padding="18">
|
||||||
|
<StackPanel><TextBlock Text="Add Manual Hikvision Device" FontWeight="SemiBold" Margin="0,0,0,10"/>
|
||||||
|
<WrapPanel>
|
||||||
|
<TextBox x:Name="DeviceNameBox" Width="180" Margin="0,0,10,8" ToolTip="Machine name"/>
|
||||||
|
<TextBox x:Name="MachineIdBox" Width="120" Margin="0,0,10,8" ToolTip="Machine ID"/>
|
||||||
|
<TextBox x:Name="IpAddressBox" Width="140" Margin="0,0,10,8" ToolTip="IP Address"/>
|
||||||
|
<TextBox x:Name="PortBox" Width="75" Margin="0,0,10,8" Text="8000" ToolTip="Port"/>
|
||||||
|
<TextBox x:Name="UsernameBox" Width="120" Margin="0,0,10,8" ToolTip="Username"/>
|
||||||
|
<PasswordBox x:Name="PasswordBox" Width="120" Margin="0,0,10,8" ToolTip="Password"/>
|
||||||
|
<TextBox x:Name="ModelBox" Width="140" Margin="0,0,10,8" ToolTip="Model"/>
|
||||||
|
<Button x:Name="EditorTestConnectionButton" Content="Test Connection" Margin="0,0,10,8" Click="TestEditorConnection_Click"/>
|
||||||
|
<Button Content="Add Device" Style="{StaticResource PrimaryButton}" Margin="0,0,8,8" Click="SaveDevice_Click"/>
|
||||||
|
<Button Content="Cancel" Margin="0,0,0,8" Click="CancelEdit_Click"/>
|
||||||
|
</WrapPanel></StackPanel>
|
||||||
|
</Border>
|
||||||
|
<Border Grid.Row="2" Style="{StaticResource CardStyle}" Padding="0">
|
||||||
|
<DockPanel>
|
||||||
|
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="16">
|
||||||
|
<Button x:Name="DevicesTestConnectionButton" Content="Test Connection" Style="{StaticResource PrimaryButton}" Click="TestConnection_Click"/>
|
||||||
|
<Button Content="View Device Information" Margin="8,0,0,0" Click="DeviceInformation_Click"/>
|
||||||
|
<Button Content="Edit" Margin="8,0,0,0" Click="EditDevice_Click"/>
|
||||||
|
<Button Content="Remove" Margin="8,0,0,0" Foreground="#B91C1C" Click="RemoveDevice_Click"/>
|
||||||
|
<TextBlock x:Name="ConnectionMessageText" VerticalAlignment="Center" Margin="18,0,0,0" Foreground="#66758C"/>
|
||||||
|
</StackPanel>
|
||||||
|
<DataGrid x:Name="DevicesGrid" ItemsSource="{Binding Devices}" SelectedItem="{Binding SelectedDevice, Mode=TwoWay}" AutoGenerateColumns="False" CanUserAddRows="False" IsReadOnly="True" BorderThickness="0" HeadersVisibility="Column" RowHeight="44">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="Machine Name" Binding="{Binding Name}" Width="*"/>
|
||||||
|
<DataGridTextColumn Header="Machine ID" Binding="{Binding MachineId}" Width="110"/>
|
||||||
|
<DataGridTextColumn Header="IP Address" Binding="{Binding IpAddress}" Width="140"/>
|
||||||
|
<DataGridTextColumn Header="Port" Binding="{Binding Port}" Width="70"/>
|
||||||
|
<DataGridTextColumn Header="Model" Binding="{Binding Model}" Width="150"/>
|
||||||
|
<DataGridTextColumn Header="Source" Binding="{Binding Source}" Width="80"/>
|
||||||
|
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="120"/>
|
||||||
|
<DataGridTextColumn Header="Registered Users" Binding="{Binding RegisteredUserCountDisplay}" Width="120"/>
|
||||||
|
<DataGridTextColumn Header="Last Sync" Binding="{Binding LastSyncDisplay}" Width="150"/>
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid x:Name="UserManagementPage" Visibility="Collapsed">
|
||||||
|
<local:UserManagementView x:Name="UserManagementControl"/>
|
||||||
|
</Grid>
|
||||||
|
<Grid x:Name="AttendanceSyncPage" Visibility="Collapsed">
|
||||||
|
<local:AttendanceSyncView x:Name="AttendanceSyncControl"/>
|
||||||
|
</Grid>
|
||||||
|
<Grid x:Name="DeviceToDbPage" Visibility="Collapsed">
|
||||||
|
<local:DeviceToDbView x:Name="DeviceToDbControl"/>
|
||||||
|
</Grid>
|
||||||
|
<Grid x:Name="DbToDevicePage" Visibility="Collapsed">
|
||||||
|
<local:DbToDeviceView x:Name="DbToDeviceControl"/>
|
||||||
|
</Grid>
|
||||||
|
<Grid x:Name="DepartmentalSyncPage" Visibility="Collapsed">
|
||||||
|
<local:DepartmentalUserSyncView x:Name="DepartmentalSyncControl"/>
|
||||||
|
</Grid>
|
||||||
|
<Grid x:Name="SyncHistoryPage" Visibility="Collapsed">
|
||||||
|
<local:SyncHistoryView x:Name="SyncHistoryControl"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid x:Name="PlaceholderPage" Visibility="Collapsed">
|
||||||
|
<Border Style="{StaticResource CardStyle}" HorizontalAlignment="Center" VerticalAlignment="Center" Width="470" Padding="34">
|
||||||
|
<StackPanel HorizontalAlignment="Center"><TextBlock x:Name="PlaceholderTitle" FontSize="22" FontWeight="SemiBold" Foreground="#17233B" HorizontalAlignment="Center"/><TextBlock Text="This operator workflow is planned for a later implementation phase." Margin="0,14,0,0" TextAlignment="Center" Foreground="#66758C"/></StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
|
|
@ -0,0 +1,386 @@
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using HikvisionAttendanceManager.App.Models;
|
||||||
|
using HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App;
|
||||||
|
|
||||||
|
public partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
private readonly DeviceService _deviceService = new();
|
||||||
|
private readonly DeviceConnectionService _connectionService = new();
|
||||||
|
private readonly DashboardService _dashboardService = new();
|
||||||
|
private readonly SemaphoreSlim _dashboardRefreshLock = new(1, 1);
|
||||||
|
private Device? _editingDevice;
|
||||||
|
|
||||||
|
public DashboardViewModel Dashboard { get; } = new();
|
||||||
|
public ObservableCollection<Device> Devices { get; private set; } = [];
|
||||||
|
public Device? SelectedDevice { get; set; }
|
||||||
|
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
DataContext = this;
|
||||||
|
AppLogger.Info("[STARTUP] MainWindow created");
|
||||||
|
ContentRendered += MainWindow_ContentRendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MainWindow_ContentRendered(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
ContentRendered -= MainWindow_ContentRendered;
|
||||||
|
_ = InitializeAfterDisplayAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InitializeAfterDisplayAsync()
|
||||||
|
{
|
||||||
|
AppLogger.Info("[STARTUP] MainWindow displayed");
|
||||||
|
AppLogger.Info("[STARTUP] HRMS initialization started");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||||
|
AppLogger.Info("[STARTUP] Device loading started");
|
||||||
|
Devices = await Task.Run(async () => await _deviceService.LoadUnifiedAsync(timeout.Token), timeout.Token);
|
||||||
|
DevicesGrid.ItemsSource = Devices;
|
||||||
|
ConnectionMessageText.Text = Devices.Count == 0 ? "No devices available from HRMS or manual storage." : "";
|
||||||
|
AppLogger.Info("[STARTUP] Device loading completed");
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
Devices = [];
|
||||||
|
DevicesGrid.ItemsSource = Devices;
|
||||||
|
ConnectionMessageText.Text = "HRMS: Connection timed out. You can retry from Devices.";
|
||||||
|
AppLogger.Warning("[STARTUP] HRMS initialization timed out.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Error("Initial device loading failed.", ex);
|
||||||
|
Devices = [];
|
||||||
|
DevicesGrid.ItemsSource = Devices;
|
||||||
|
ConnectionMessageText.Text = "Unable to load HRMS devices. See the application log for details.";
|
||||||
|
}
|
||||||
|
await LoadDashboardAsync();
|
||||||
|
AppLogger.Info("[STARTUP] HRMS initialization completed");
|
||||||
|
AppLogger.Info("[STARTUP] Dashboard refresh completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task LoadDashboardAsync() => RefreshDashboardAsync(reloadDevices: false);
|
||||||
|
|
||||||
|
private void Navigate_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var page = (string)((Button)sender).Tag;
|
||||||
|
DashboardPage.Visibility = page == "Dashboard" ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
DevicesPage.Visibility = page == "Devices" ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
UserManagementPage.Visibility = page == "User Management" ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
AttendanceSyncPage.Visibility = page == "Attendance Sync" ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
DeviceToDbPage.Visibility = page == "Device → DB" ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
DbToDevicePage.Visibility = page == "DB → Device" ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
DepartmentalSyncPage.Visibility = page == "Departmental User Sync" ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
SyncHistoryPage.Visibility = page == "Sync History" ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
PlaceholderPage.Visibility = page == "Settings" ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
|
||||||
|
if (PlaceholderPage.Visibility == Visibility.Visible)
|
||||||
|
PlaceholderTitle.Text = page;
|
||||||
|
|
||||||
|
_ = page switch
|
||||||
|
{
|
||||||
|
"User Management" => UserManagementControl.InitializeAsync(),
|
||||||
|
"Attendance Sync" => AttendanceSyncControl.InitializeAsync(),
|
||||||
|
"Device → DB" => DeviceToDbControl.InitializeAsync(),
|
||||||
|
"DB → Device" => DbToDeviceControl.InitializeAsync(),
|
||||||
|
"Departmental User Sync" => DepartmentalSyncControl.InitializeAsync(),
|
||||||
|
"Sync History" => SyncHistoryControl.InitializeAsync(),
|
||||||
|
"Dashboard" => LoadDashboardAsync(),
|
||||||
|
_ => Task.CompletedTask
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshDashboardAsync(bool reloadDevices = false)
|
||||||
|
{
|
||||||
|
if (!await _dashboardRefreshLock.WaitAsync(0).ConfigureAwait(true))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var originalButtonContent = RefreshDashboardButton.Content;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RefreshDashboardButton.IsEnabled = false;
|
||||||
|
RefreshDashboardButton.Content = "Refreshing…";
|
||||||
|
|
||||||
|
if (reloadDevices)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Devices = await _deviceService.LoadUnifiedAsync().ConfigureAwait(true);
|
||||||
|
DevicesGrid.ItemsSource = Devices;
|
||||||
|
DashboardErrorText.Visibility = Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Error("Dashboard refresh: device reload failed.", ex);
|
||||||
|
DashboardErrorText.Text = "Unable to load device data.\nCheck the HRMS connection.";
|
||||||
|
DashboardErrorText.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DashboardService.NormalizeConnectivityStatus(Devices);
|
||||||
|
Dashboard.UpdateFromDevices(Devices);
|
||||||
|
Dashboard.IsInitialLoad = false;
|
||||||
|
|
||||||
|
var hrmsError = await DashboardService.DetectHrmsErrorAsync(Devices.ToList()).ConfigureAwait(true);
|
||||||
|
if (!string.IsNullOrWhiteSpace(hrmsError))
|
||||||
|
{
|
||||||
|
DashboardErrorText.Text = hrmsError;
|
||||||
|
DashboardErrorText.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DashboardErrorText.Visibility = Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dashboard.IsLoadingConnectivity = true;
|
||||||
|
await _dashboardService.ProbeConnectivityAsync(Devices.ToList()).ConfigureAwait(true);
|
||||||
|
Dashboard.IsLoadingConnectivity = false;
|
||||||
|
Dashboard.UpdateFromDevices(Devices);
|
||||||
|
|
||||||
|
var history = await _dashboardService.LoadRecentHistoryAsync().ConfigureAwait(true);
|
||||||
|
var latestHrmsSync = DashboardService.GetLatestHrmsSync(Devices);
|
||||||
|
LastHrmsSyncText.Text = latestHrmsSync.HasValue
|
||||||
|
? $"Latest HRMS sync:\n{latestHrmsSync:dd-MMM-yyyy HH:mm}"
|
||||||
|
: "No HRMS sync recorded";
|
||||||
|
|
||||||
|
ActivityText.Text = history.Count == 0
|
||||||
|
? (Devices.Count == 0
|
||||||
|
? "No activity yet. Add a device to get started."
|
||||||
|
: "No sync history yet.")
|
||||||
|
: string.Join("\n", history.Take(5).Select(h =>
|
||||||
|
$"{h.StartedAt:dd-MMM HH:mm} · {h.OperationDisplay} · {h.Status} · OK {h.Success}/{h.Total}"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Error("Dashboard refresh failed.", ex);
|
||||||
|
DashboardErrorText.Text = "Unable to load device data.\nCheck the HRMS connection.";
|
||||||
|
DashboardErrorText.Visibility = Visibility.Visible;
|
||||||
|
Dashboard.IsInitialLoad = false;
|
||||||
|
Dashboard.IsLoadingConnectivity = false;
|
||||||
|
Dashboard.UpdateFromDevices(Devices);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Dashboard.IsLoadingConnectivity = false;
|
||||||
|
RefreshDashboardButton.IsEnabled = true;
|
||||||
|
RefreshDashboardButton.Content = originalButtonContent;
|
||||||
|
_dashboardRefreshLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshDashboard() => _ = LoadDashboardAsync();
|
||||||
|
|
||||||
|
private async void RefreshDashboard_Click(object sender, RoutedEventArgs e) =>
|
||||||
|
await RefreshDashboardAsync(reloadDevices: true);
|
||||||
|
|
||||||
|
private async void RefreshDevices_Click(object sender, RoutedEventArgs e) => await ReloadDevicesAsync();
|
||||||
|
|
||||||
|
private async Task ReloadDevicesAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Devices = await _deviceService.LoadUnifiedAsync();
|
||||||
|
ConnectionMessageText.Text = "Loaded latest devices from HRMS and manual storage.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ConnectionMessageText.Text = "Unable to load HRMS devices: " + ex.Message;
|
||||||
|
}
|
||||||
|
DevicesGrid.ItemsSource = Devices;
|
||||||
|
await LoadDashboardAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddDevice_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_editingDevice = null;
|
||||||
|
DeviceNameBox.Text = "";
|
||||||
|
MachineIdBox.Text = "";
|
||||||
|
IpAddressBox.Text = "";
|
||||||
|
PortBox.Text = "8000";
|
||||||
|
UsernameBox.Text = "";
|
||||||
|
PasswordBox.Clear();
|
||||||
|
ModelBox.Text = "";
|
||||||
|
DeviceEditor.Visibility = Visibility.Visible;
|
||||||
|
DeviceNameBox.Focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EditDevice_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!EnsureSelectedDevice()) return;
|
||||||
|
if (!string.Equals(SelectedDevice!.Source, "Manual", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
MessageBox.Show("HRMS devices are read-only here. Update the machine in HRMS.", "HRMS device", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_editingDevice = SelectedDevice;
|
||||||
|
DeviceNameBox.Text = SelectedDevice!.Name;
|
||||||
|
MachineIdBox.Text = SelectedDevice.MachineId;
|
||||||
|
IpAddressBox.Text = SelectedDevice.IpAddress;
|
||||||
|
PortBox.Text = SelectedDevice.Port.ToString();
|
||||||
|
UsernameBox.Text = SelectedDevice.Username ?? "";
|
||||||
|
ModelBox.Text = SelectedDevice.Model;
|
||||||
|
DeviceEditor.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void SaveDevice_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(DeviceNameBox.Text) || string.IsNullOrWhiteSpace(IpAddressBox.Text) ||
|
||||||
|
!int.TryParse(PortBox.Text, out var port) || port is < 1 or > 65535)
|
||||||
|
{
|
||||||
|
MessageBox.Show("Enter a device name, a valid IP address, and a port between 1 and 65535.", "Check device details", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_editingDevice is null)
|
||||||
|
{
|
||||||
|
SelectedDevice = new Device();
|
||||||
|
Devices.Add(SelectedDevice);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SelectedDevice = _editingDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectedDevice.Name = DeviceNameBox.Text.Trim();
|
||||||
|
SelectedDevice.MachineId = MachineIdBox.Text.Trim();
|
||||||
|
SelectedDevice.IpAddress = IpAddressBox.Text.Trim();
|
||||||
|
SelectedDevice.Port = port;
|
||||||
|
SelectedDevice.Username = UsernameBox.Text.Trim();
|
||||||
|
SelectedDevice.Model = ModelBox.Text.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(PasswordBox.Password))
|
||||||
|
SelectedDevice.ProtectedPassword = PasswordProtector.Protect(PasswordBox.Password);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _deviceService.SaveManualAsync(SelectedDevice, Devices);
|
||||||
|
Devices = await _deviceService.LoadUnifiedAsync();
|
||||||
|
DevicesGrid.ItemsSource = Devices;
|
||||||
|
DeviceEditor.Visibility = Visibility.Collapsed;
|
||||||
|
await RefreshDashboardAsync();
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
if (_editingDevice is null) Devices.Remove(SelectedDevice);
|
||||||
|
MessageBox.Show(ex.Message, "Device already exists", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelEdit_Click(object sender, RoutedEventArgs e) => DeviceEditor.Visibility = Visibility.Collapsed;
|
||||||
|
|
||||||
|
private async void TestConnection_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!EnsureSelectedDevice()) return;
|
||||||
|
if (sender is not Button button) return;
|
||||||
|
if (!button.IsEnabled) return;
|
||||||
|
|
||||||
|
var originalContent = button.Content;
|
||||||
|
button.IsEnabled = false;
|
||||||
|
button.Content = "Testing…";
|
||||||
|
ConnectionMessageText.Text = "Testing connection…";
|
||||||
|
ConnectionMessageText.Foreground = System.Windows.Media.Brushes.Gray;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _connectionService.TestAsync(SelectedDevice!, CancellationToken.None).ConfigureAwait(true);
|
||||||
|
SelectedDevice!.Status = result.IsConnected ? "Online" : "Offline";
|
||||||
|
ConnectionMessageText.Text = result.IsConnected ? "✓ " + result.Message : "✕ Connection failed\n" + result.Message;
|
||||||
|
ConnectionMessageText.Foreground = result.IsConnected
|
||||||
|
? System.Windows.Media.Brushes.ForestGreen
|
||||||
|
: System.Windows.Media.Brushes.Firebrick;
|
||||||
|
if (result.IsConnected && !string.IsNullOrWhiteSpace(result.Model))
|
||||||
|
SelectedDevice.Model = result.Model!;
|
||||||
|
if (string.Equals(SelectedDevice.Source, "Manual", StringComparison.OrdinalIgnoreCase))
|
||||||
|
await _deviceService.SaveManualAsync(SelectedDevice, Devices);
|
||||||
|
DevicesGrid.Items.Refresh();
|
||||||
|
await RefreshDashboardAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Error("[DEVICE_TEST] Unexpected UI failure.", ex);
|
||||||
|
ConnectionMessageText.Text = "✕ Connection failed\nUnexpected error. See the application log.";
|
||||||
|
ConnectionMessageText.Foreground = System.Windows.Media.Brushes.Firebrick;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
button.IsEnabled = true;
|
||||||
|
button.Content = originalContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeviceInformation_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!EnsureSelectedDevice()) return;
|
||||||
|
MessageBox.Show(
|
||||||
|
$"{SelectedDevice!.Name}\n\nMachine ID: {SelectedDevice.MachineId}\nEndpoint: {SelectedDevice.Endpoint}\nModel: {SelectedDevice.Model}\nSource: {SelectedDevice.Source}\nStatus: {SelectedDevice.Status}\nRegistered users: {SelectedDevice.RegisteredUserCountDisplay}\nLast sync: {SelectedDevice.LastSyncDisplay}",
|
||||||
|
"Device Information", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void RemoveDevice_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!EnsureSelectedDevice()) return;
|
||||||
|
if (MessageBox.Show($"Remove {SelectedDevice!.Name} from this application?", "Remove Device", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes) return;
|
||||||
|
if (!string.Equals(SelectedDevice!.Source, "Manual", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
MessageBox.Show("This device comes from HRMS. Manage or remove it in HRMS.", "HRMS device", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _deviceService.RemoveManualAsync(SelectedDevice);
|
||||||
|
Devices.Remove(SelectedDevice);
|
||||||
|
SelectedDevice = null;
|
||||||
|
await RefreshDashboardAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void TestEditorConnection_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not Button button) return;
|
||||||
|
if (!int.TryParse(PortBox.Text, out var port)) { MessageBox.Show("Enter a valid port."); return; }
|
||||||
|
|
||||||
|
var originalContent = button.Content;
|
||||||
|
button.IsEnabled = false;
|
||||||
|
button.Content = "Testing…";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var testDevice = new Device
|
||||||
|
{
|
||||||
|
Name = DeviceNameBox.Text.Trim(),
|
||||||
|
IpAddress = IpAddressBox.Text.Trim(),
|
||||||
|
Port = port,
|
||||||
|
Username = UsernameBox.Text.Trim()
|
||||||
|
};
|
||||||
|
if (!string.IsNullOrWhiteSpace(PasswordBox.Password))
|
||||||
|
testDevice.ProtectedPassword = PasswordProtector.Protect(PasswordBox.Password);
|
||||||
|
else if (_editingDevice?.ProtectedPassword is not null)
|
||||||
|
testDevice.ProtectedPassword = _editingDevice.ProtectedPassword;
|
||||||
|
|
||||||
|
var result = await _connectionService.TestAsync(testDevice, CancellationToken.None).ConfigureAwait(true);
|
||||||
|
MessageBox.Show(
|
||||||
|
result.IsConnected ? "✓ " + result.Message : "✕ Connection failed\n" + result.Message,
|
||||||
|
"Connection Test",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
result.IsConnected ? MessageBoxImage.Information : MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Error("[DEVICE_TEST] Unexpected editor test failure.", ex);
|
||||||
|
MessageBox.Show("✕ Connection failed\nUnexpected error. See the application log.", "Connection Test", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
button.IsEnabled = true;
|
||||||
|
button.Content = originalContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool EnsureSelectedDevice()
|
||||||
|
{
|
||||||
|
SelectedDevice = DevicesGrid.SelectedItem as Device ?? SelectedDevice;
|
||||||
|
if (SelectedDevice is not null) return true;
|
||||||
|
MessageBox.Show("Select a device first.", "No device selected", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App.Models;
|
||||||
|
|
||||||
|
public sealed class DashboardViewModel : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
private int _totalDevices;
|
||||||
|
private int _onlineDevices;
|
||||||
|
private int _offlineDevices;
|
||||||
|
private int _notTestedDevices;
|
||||||
|
private int _totalUsersOnDevices;
|
||||||
|
private bool _isInitialLoad = true;
|
||||||
|
private bool _isLoadingConnectivity;
|
||||||
|
|
||||||
|
public int TotalDevices
|
||||||
|
{
|
||||||
|
get => _totalDevices;
|
||||||
|
set => SetField(ref _totalDevices, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int OnlineDevices
|
||||||
|
{
|
||||||
|
get => _onlineDevices;
|
||||||
|
set => SetField(ref _onlineDevices, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int OfflineDevices
|
||||||
|
{
|
||||||
|
get => _offlineDevices;
|
||||||
|
set => SetField(ref _offlineDevices, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int NotTestedDevices
|
||||||
|
{
|
||||||
|
get => _notTestedDevices;
|
||||||
|
set => SetField(ref _notTestedDevices, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int TotalUsersOnDevices
|
||||||
|
{
|
||||||
|
get => _totalUsersOnDevices;
|
||||||
|
set => SetField(ref _totalUsersOnDevices, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsInitialLoad
|
||||||
|
{
|
||||||
|
get => _isInitialLoad;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetField(ref _isInitialLoad, value))
|
||||||
|
NotifyDisplayProperties();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsLoadingConnectivity
|
||||||
|
{
|
||||||
|
get => _isLoadingConnectivity;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetField(ref _isLoadingConnectivity, value))
|
||||||
|
NotifyConnectivityDisplays();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string TotalDevicesDisplay => IsInitialLoad ? "Loading..." : TotalDevices.ToString("N0");
|
||||||
|
public string OnlineDevicesDisplay => IsInitialLoad || IsLoadingConnectivity ? "Loading..." : OnlineDevices.ToString("N0");
|
||||||
|
public string OfflineDevicesDisplay => IsInitialLoad || IsLoadingConnectivity ? "Loading..." : OfflineDevices.ToString("N0");
|
||||||
|
public string TotalUsersDisplay => IsInitialLoad ? "Loading..." : TotalUsersOnDevices.ToString("N0");
|
||||||
|
|
||||||
|
public string DeviceStatusSummary => TotalDevices == 0
|
||||||
|
? "No devices configured"
|
||||||
|
: $"{OnlineDevices} online · {OfflineDevices} offline · {NotTestedDevices} not tested";
|
||||||
|
|
||||||
|
public void UpdateFromDevices(IEnumerable<Device> devices)
|
||||||
|
{
|
||||||
|
var list = devices as IReadOnlyList<Device> ?? devices.ToList();
|
||||||
|
TotalDevices = list.Count;
|
||||||
|
OnlineDevices = list.Count(d => IsStatus(d, "Online"));
|
||||||
|
OfflineDevices = list.Count(d => IsStatus(d, "Offline"));
|
||||||
|
NotTestedDevices = list.Count(d => IsStatus(d, "Not Tested"));
|
||||||
|
TotalUsersOnDevices = list.Sum(d => d.RegisteredUserCount ?? 0);
|
||||||
|
OnPropertyChanged(nameof(DeviceStatusSummary));
|
||||||
|
NotifyDisplayProperties();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NotifyDisplayProperties()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(TotalDevicesDisplay));
|
||||||
|
NotifyConnectivityDisplays();
|
||||||
|
OnPropertyChanged(nameof(TotalUsersDisplay));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NotifyConnectivityDisplays()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(OnlineDevicesDisplay));
|
||||||
|
OnPropertyChanged(nameof(OfflineDevicesDisplay));
|
||||||
|
OnPropertyChanged(nameof(DeviceStatusSummary));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsStatus(Device device, string status) =>
|
||||||
|
string.Equals(device.Status, status, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
private void OnPropertyChanged([CallerMemberName] string? propertyName = null) =>
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||||
|
|
||||||
|
private bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
|
||||||
|
{
|
||||||
|
if (EqualityComparer<T>.Default.Equals(field, value))
|
||||||
|
return false;
|
||||||
|
field = value;
|
||||||
|
OnPropertyChanged(propertyName);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App.Models;
|
||||||
|
|
||||||
|
public sealed class Device : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
private string _name = "";
|
||||||
|
private string _status = "Unknown";
|
||||||
|
private int? _registeredUserCount;
|
||||||
|
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
public string MachineId { get; set; } = "";
|
||||||
|
public string IpAddress { get; set; } = "";
|
||||||
|
public int Port { get; set; } = 8000;
|
||||||
|
public string Model { get; set; } = "";
|
||||||
|
public string Source { get; set; } = "Manual";
|
||||||
|
public DateTime? LastSync { get; set; }
|
||||||
|
public string? Username { get; set; }
|
||||||
|
public string? ProtectedPassword { get; set; }
|
||||||
|
|
||||||
|
public string Name
|
||||||
|
{
|
||||||
|
get => _name;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_name == value) return;
|
||||||
|
_name = value;
|
||||||
|
NotifyStatusDisplay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Status
|
||||||
|
{
|
||||||
|
get => _status;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_status == value) return;
|
||||||
|
_status = value;
|
||||||
|
NotifyStatusDisplay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int? RegisteredUserCount
|
||||||
|
{
|
||||||
|
get => _registeredUserCount;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_registeredUserCount == value) return;
|
||||||
|
_registeredUserCount = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
OnPropertyChanged(nameof(RegisteredUserCountDisplay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>HTTP ISAPI port (SDK login often uses 8000; ISAPI typically uses 80).</summary>
|
||||||
|
public int IsapiPort => Port is 8000 or 37777 ? 80 : Port;
|
||||||
|
|
||||||
|
public string Endpoint => $"{IpAddress}:{Port}";
|
||||||
|
public string IsapiEndpoint => $"{IpAddress}:{IsapiPort}";
|
||||||
|
public string LastSyncDisplay => LastSync?.ToString("dd-MMM-yyyy HH:mm") ?? "—";
|
||||||
|
public string RegisteredUserCountDisplay => RegisteredUserCount?.ToString("N0") ?? "—";
|
||||||
|
public string DisplayName => $"{Name} - {IpAddress}";
|
||||||
|
|
||||||
|
public string DashboardListLine => Status switch
|
||||||
|
{
|
||||||
|
"Online" => $"● {Name} Online",
|
||||||
|
"Offline" => $"● {Name} Offline",
|
||||||
|
_ => $"● {Name} Not Tested"
|
||||||
|
};
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
private void NotifyStatusDisplay()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(Status));
|
||||||
|
OnPropertyChanged(nameof(DashboardListLine));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPropertyChanged([CallerMemberName] string? propertyName = null) =>
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
namespace HikvisionAttendanceManager.App.Models;
|
||||||
|
|
||||||
|
public sealed record HikvisionUser(string EmployeeNo, string Name, int FaceCount);
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
namespace HikvisionAttendanceManager.App.Models;
|
||||||
|
|
||||||
|
public enum SyncOperationKind
|
||||||
|
{
|
||||||
|
AttendanceSync,
|
||||||
|
UserCreation,
|
||||||
|
UserDeletion,
|
||||||
|
DeviceToDb,
|
||||||
|
DbToDevice,
|
||||||
|
DepartmentalUserSync,
|
||||||
|
FaceEnrollment
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SyncHistoryEntry
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
public DateTime StartedAt { get; set; } = DateTime.Now;
|
||||||
|
public DateTime? CompletedAt { get; set; }
|
||||||
|
public SyncOperationKind Operation { get; set; }
|
||||||
|
public string SourceDevice { get; set; } = "";
|
||||||
|
public string TargetDevice { get; set; } = "";
|
||||||
|
public string Context { get; set; } = "";
|
||||||
|
public int Total { get; set; }
|
||||||
|
public int Success { get; set; }
|
||||||
|
public int Failed { get; set; }
|
||||||
|
public int Skipped { get; set; }
|
||||||
|
public string Status { get; set; } = "Running";
|
||||||
|
|
||||||
|
public string OperationDisplay => Operation switch
|
||||||
|
{
|
||||||
|
SyncOperationKind.AttendanceSync => "Attendance Sync",
|
||||||
|
SyncOperationKind.UserCreation => "User Creation",
|
||||||
|
SyncOperationKind.UserDeletion => "User Deletion",
|
||||||
|
SyncOperationKind.DeviceToDb => "Device → DB",
|
||||||
|
SyncOperationKind.DbToDevice => "DB → Device",
|
||||||
|
SyncOperationKind.DepartmentalUserSync => "Departmental User Sync",
|
||||||
|
SyncOperationKind.FaceEnrollment => "Face Enrollment",
|
||||||
|
_ => Operation.ToString()
|
||||||
|
};
|
||||||
|
|
||||||
|
public string StartedAtDisplay => StartedAt.ToString("dd-MMM-yyyy HH:mm:ss");
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SelectableDepartment : System.ComponentModel.INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
private bool _isSelected;
|
||||||
|
|
||||||
|
public SelectableDepartment(HrmsDepartment department) => Department = department;
|
||||||
|
|
||||||
|
public HrmsDepartment Department { get; }
|
||||||
|
public string Name => Department.Name;
|
||||||
|
|
||||||
|
public bool IsSelected
|
||||||
|
{
|
||||||
|
get => _isSelected;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_isSelected == value) return;
|
||||||
|
_isSelected = value;
|
||||||
|
PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(IsSelected)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SelectableEmployee : System.ComponentModel.INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
private bool _isSelected;
|
||||||
|
|
||||||
|
public SelectableEmployee(HrmsEmployee employee, string locationSiteDisplay, bool existingOnDevice)
|
||||||
|
{
|
||||||
|
Employee = employee;
|
||||||
|
LocationSiteDisplay = locationSiteDisplay;
|
||||||
|
ExistingOnDevice = existingOnDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HrmsEmployee Employee { get; }
|
||||||
|
public string SerialNumber => Employee.SerialNumber;
|
||||||
|
public string Name => Employee.Name;
|
||||||
|
public string Department => Employee.DepartmentName;
|
||||||
|
public string LocationSiteDisplay { get; }
|
||||||
|
public string ActiveDisplay => Employee.Active ? "Yes" : "No";
|
||||||
|
public string ExistingOnDeviceDisplay => ExistingOnDevice ? "Yes" : "No";
|
||||||
|
public string PhotoStatus => Employee.HasPhoto ? "Photo in HRMS" : "No photo";
|
||||||
|
public bool ExistingOnDevice { get; }
|
||||||
|
|
||||||
|
public bool IsSelected
|
||||||
|
{
|
||||||
|
get => _isSelected;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_isSelected == value) return;
|
||||||
|
_isSelected = value;
|
||||||
|
PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(IsSelected)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SelectableMachineUser : System.ComponentModel.INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
private bool _isSelected;
|
||||||
|
|
||||||
|
public SelectableMachineUser(string serialNumber, string employeeName, bool hasFaceTemplate)
|
||||||
|
{
|
||||||
|
SerialNumber = serialNumber;
|
||||||
|
EmployeeName = employeeName;
|
||||||
|
HasFaceTemplate = hasFaceTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string SerialNumber { get; }
|
||||||
|
public string EmployeeName { get; }
|
||||||
|
public bool HasFaceTemplate { get; }
|
||||||
|
|
||||||
|
public bool IsSelected
|
||||||
|
{
|
||||||
|
get => _isSelected;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_isSelected == value) return;
|
||||||
|
_isSelected = value;
|
||||||
|
PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(IsSelected)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AttendanceSyncProgress(int Fetched, int Inserted, int Skipped, int Failed, string Message);
|
||||||
|
|
||||||
|
public sealed record AttendancePunch(string EmployeeNo, DateTime CheckTime, int InOutTypeId);
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
namespace HikvisionAttendanceManager.App.Models;
|
||||||
|
|
||||||
|
public sealed record HrmsEmployee(long Id, string SerialNumber, string Name, long? DepartmentId, bool Active, long? LocationSiteId, string DepartmentName, bool DepartmentActive, bool HasPhoto = false);
|
||||||
|
public sealed record HrmsDepartment(long Id, string Name, bool Active);
|
||||||
|
public sealed record LocationSite(long Id, string Name)
|
||||||
|
{
|
||||||
|
public string DisplayName => $"{Name} (ID: {Id})";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class EmployeeSyncResult
|
||||||
|
{
|
||||||
|
public string EmployeeNumber { get; init; } = "";
|
||||||
|
public string EmployeeName { get; init; } = "";
|
||||||
|
public string Department { get; init; } = "";
|
||||||
|
public string UserStatus { get; set; } = "Not started";
|
||||||
|
public string FaceStatus { get; set; } = "Not requested";
|
||||||
|
public string VerificationStatus { get; set; } = "Not requested";
|
||||||
|
public string OverallResult { get; set; } = "SKIPPED";
|
||||||
|
public string Reason { get; set; } = "";
|
||||||
|
public HrmsEmployee? Employee { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record UserSyncProgress(int Completed, int Total, string CurrentEmployee, string CurrentOperation, int Created, int AlreadyExists, int FacesUploaded, int FacesFailed, int Skipped, int Failed);
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
/// <summary>Small application logger. Callers must never pass credentials or connection strings.</summary>
|
||||||
|
public static class AppLogger
|
||||||
|
{
|
||||||
|
public static readonly string LogDirectory = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonDocuments),
|
||||||
|
"HikVisionAttMachine",
|
||||||
|
"Logs");
|
||||||
|
|
||||||
|
private static readonly SemaphoreSlim Gate = new(1, 1);
|
||||||
|
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(LogDirectory);
|
||||||
|
Info("Hikvision Attendance Manager started.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Info(string message) => Write("INFO", message);
|
||||||
|
public static void Warning(string message) => Write("WARN", message);
|
||||||
|
public static void Error(string message, Exception? exception = null) =>
|
||||||
|
Write("ERROR", exception is null ? message : $"{message} Error: {exception.Message}");
|
||||||
|
|
||||||
|
private static void Write(string level, string message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(LogDirectory);
|
||||||
|
var timestamp = DateTime.Now;
|
||||||
|
var line = $"{timestamp:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message}{Environment.NewLine}";
|
||||||
|
Gate.Wait();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.AppendAllText(Path.Combine(LogDirectory, $"HikVisionAttMachine-{timestamp:yyyyMMdd}.log"), line, Encoding.UTF8);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Logging must never prevent an operator workflow from continuing.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
using MySql.Data.MySqlClient;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
public sealed class AttendanceLogRepository
|
||||||
|
{
|
||||||
|
public async Task<bool> InsertAsync(string acNo, DateTime checkTime, string machineId, int inOutTypeId, string machineIp, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(acNo)) return false;
|
||||||
|
await using var connection = HrmsConnectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
const string sql = """
|
||||||
|
INSERT INTO attendance_log (ac_no,checktime,processed,machine_id,in_out_type_id,machine_ip,date)
|
||||||
|
VALUES (@ac_no,@checktime,@processed,@machine_id,@in_out_type_id,@machine_ip,@date)
|
||||||
|
""";
|
||||||
|
await using var command = new MySqlCommand(sql, connection);
|
||||||
|
command.Parameters.AddWithValue("@ac_no", acNo);
|
||||||
|
command.Parameters.AddWithValue("@checktime", checkTime);
|
||||||
|
command.Parameters.AddWithValue("@processed", 0);
|
||||||
|
command.Parameters.AddWithValue("@machine_id", string.IsNullOrWhiteSpace(machineId) ? DBNull.Value : machineId);
|
||||||
|
command.Parameters.AddWithValue("@in_out_type_id", inOutTypeId);
|
||||||
|
command.Parameters.AddWithValue("@machine_ip", string.IsNullOrWhiteSpace(machineIp) ? DBNull.Value : machineIp);
|
||||||
|
command.Parameters.AddWithValue("@date", checkTime.Date);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ExistsAsync(string acNo, DateTime checkTime, string machineId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = HrmsConnectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
const string sql = """
|
||||||
|
SELECT 1 FROM attendance_log
|
||||||
|
WHERE ac_no=@ac_no AND checktime=@checktime AND machine_id=@machine_id
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
await using var command = new MySqlCommand(sql, connection);
|
||||||
|
command.Parameters.AddWithValue("@ac_no", acNo);
|
||||||
|
command.Parameters.AddWithValue("@checktime", checkTime);
|
||||||
|
command.Parameters.AddWithValue("@machine_id", machineId ?? "");
|
||||||
|
var result = await command.ExecuteScalarAsync(cancellationToken);
|
||||||
|
return result != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
using MySql.Data.MySqlClient;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
public sealed record FaceTemplateRow(string SerialNo, byte[] Template, DateTime CreatedDate, bool IsActive);
|
||||||
|
|
||||||
|
public sealed class AttendanceMachineFaceTemplateRepository
|
||||||
|
{
|
||||||
|
public async Task<bool> UpsertAsync(string serialNo, byte[] template, DateTime createdDate, bool isActive,
|
||||||
|
string? sourceMachineId, string? sourceMachineIp, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = HrmsConnectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
bool createdNew;
|
||||||
|
await using (var exists = new MySqlCommand(
|
||||||
|
"SELECT 1 FROM attendance_machine_face_templates WHERE serial_no=@serial_no LIMIT 1", connection))
|
||||||
|
{
|
||||||
|
exists.Parameters.AddWithValue("@serial_no", serialNo);
|
||||||
|
createdNew = await exists.ExecuteScalarAsync(cancellationToken) is null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createdNew)
|
||||||
|
{
|
||||||
|
const string insert = """
|
||||||
|
INSERT INTO attendance_machine_face_templates (serial_no,template,created_date,is_active)
|
||||||
|
VALUES (@serial_no,@template,@created_date,@is_active)
|
||||||
|
""";
|
||||||
|
await using var command = new MySqlCommand(insert, connection);
|
||||||
|
command.Parameters.AddWithValue("@serial_no", serialNo);
|
||||||
|
command.Parameters.Add("@template", MySqlDbType.Blob).Value = template;
|
||||||
|
command.Parameters.AddWithValue("@created_date", createdDate);
|
||||||
|
command.Parameters.AddWithValue("@is_active", isActive ? 1 : 0);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const string update = """
|
||||||
|
UPDATE attendance_machine_face_templates
|
||||||
|
SET template=@template, created_date=@created_date, is_active=@is_active
|
||||||
|
WHERE serial_no=@serial_no
|
||||||
|
""";
|
||||||
|
await using var command = new MySqlCommand(update, connection);
|
||||||
|
command.Parameters.AddWithValue("@serial_no", serialNo);
|
||||||
|
command.Parameters.Add("@template", MySqlDbType.Blob).Value = template;
|
||||||
|
command.Parameters.AddWithValue("@created_date", createdDate);
|
||||||
|
command.Parameters.AddWithValue("@is_active", isActive ? 1 : 0);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(sourceMachineId) || !string.IsNullOrWhiteSpace(sourceMachineIp))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var meta = new MySqlCommand(
|
||||||
|
"UPDATE attendance_machine_face_templates SET source_machine_id=@source_machine_id, source_machine_ip=@source_machine_ip WHERE serial_no=@serial_no",
|
||||||
|
connection);
|
||||||
|
meta.Parameters.AddWithValue("@source_machine_id", (object?)sourceMachineId ?? DBNull.Value);
|
||||||
|
meta.Parameters.AddWithValue("@source_machine_ip", (object?)sourceMachineIp ?? DBNull.Value);
|
||||||
|
meta.Parameters.AddWithValue("@serial_no", serialNo);
|
||||||
|
await meta.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Optional columns on older schemas.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdNew;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<FaceTemplateRow?> TryGetActiveAsync(string serialNo, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = HrmsConnectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
await using var command = new MySqlCommand(
|
||||||
|
"SELECT serial_no,template,created_date,is_active FROM attendance_machine_face_templates WHERE serial_no=@serial_no AND is_active=1 ORDER BY created_date DESC LIMIT 1",
|
||||||
|
connection);
|
||||||
|
command.Parameters.AddWithValue("@serial_no", serialNo);
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
if (!await reader.ReadAsync(cancellationToken)) return null;
|
||||||
|
return new FaceTemplateRow(
|
||||||
|
reader["serial_no"]?.ToString() ?? "",
|
||||||
|
reader["template"] == DBNull.Value ? [] : (byte[])reader["template"],
|
||||||
|
reader["created_date"] == DBNull.Value ? DateTime.MinValue : Convert.ToDateTime(reader["created_date"]),
|
||||||
|
reader["is_active"] != DBNull.Value && Convert.ToInt32(reader["is_active"]) != 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
using MySql.Data.MySqlClient;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
public sealed class AttendanceMachineRepository
|
||||||
|
{
|
||||||
|
public async Task<DateTime?> GetLastSyncDateAsync(string machineIp, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = HrmsConnectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
await using var command = new MySqlCommand(
|
||||||
|
"SELECT last_sync_date FROM hrms.attendance_machine WHERE machine_ip=@machine_ip LIMIT 1", connection);
|
||||||
|
command.Parameters.AddWithValue("@machine_ip", machineIp);
|
||||||
|
var value = await command.ExecuteScalarAsync(cancellationToken);
|
||||||
|
return value is null or DBNull ? null : Convert.ToDateTime(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateLastSyncDateAsync(string machineIp, DateTime lastSyncDate, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = HrmsConnectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
await using var command = new MySqlCommand(
|
||||||
|
"UPDATE hrms.attendance_machine SET last_sync_date=@last_sync_date WHERE machine_ip=@machine_ip", connection);
|
||||||
|
command.Parameters.AddWithValue("@last_sync_date", lastSyncDate);
|
||||||
|
command.Parameters.AddWithValue("@machine_ip", machineIp);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
using MySql.Data.MySqlClient;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
public sealed record MachineUserRow(string SerialNumber, string EmployeeName);
|
||||||
|
|
||||||
|
/// <summary>Ports the service's attendance_machine_user upsert without depending on the service runtime.</summary>
|
||||||
|
public sealed class AttendanceMachineUserRepository
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<MachineUserRow>> GetActiveByMachineAsync(string machineId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var rows = new List<MachineUserRow>();
|
||||||
|
await using var connection = HrmsConnectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
await using var command = new MySqlCommand(
|
||||||
|
"SELECT serial_number,employee_name FROM attendance_machine_user WHERE machine_id=@machine_id AND is_deleted=0 AND is_deletion_requested=0",
|
||||||
|
connection);
|
||||||
|
command.Parameters.AddWithValue("@machine_id", machineId ?? "");
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
rows.Add(new MachineUserRow(reader["serial_number"]?.ToString() ?? "", reader["employee_name"]?.ToString() ?? ""));
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MarkDeletedAsync(string machineId, string serialNumber, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = HrmsConnectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
await using var command = new MySqlCommand(
|
||||||
|
"UPDATE attendance_machine_user SET is_deleted=1,is_deletion_requested=0,updated_by=@updated_by,updated_at=NOW() WHERE machine_id=@machine_id AND serial_number=@serial_number",
|
||||||
|
connection);
|
||||||
|
command.Parameters.AddWithValue("@updated_by", "hikvision-manager");
|
||||||
|
command.Parameters.AddWithValue("@machine_id", machineId);
|
||||||
|
command.Parameters.AddWithValue("@serial_number", serialNumber);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpsertAsync(string machineId, string serialNumber, string employeeName, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = HrmsConnectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
const string sql = """
|
||||||
|
REPLACE INTO attendance_machine_user
|
||||||
|
(machine_id,serial_number,employee_name,is_deleted,is_deletion_requested,updated_at)
|
||||||
|
VALUES (@machine_id,@serial_number,@employee_name,0,0,NOW())
|
||||||
|
""";
|
||||||
|
await using var command = new MySqlCommand(sql, connection);
|
||||||
|
command.Parameters.AddWithValue("@machine_id", machineId);
|
||||||
|
command.Parameters.AddWithValue("@serial_number", serialNumber);
|
||||||
|
command.Parameters.AddWithValue("@employee_name", employeeName);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
using HikvisionAttendanceManager.App.Models;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
public sealed class AttendanceSyncService(
|
||||||
|
HikvisionIsapiClient hikvision,
|
||||||
|
AttendanceLogRepository attendanceLog,
|
||||||
|
AttendanceMachineRepository machineRepository,
|
||||||
|
OperationHistoryService history)
|
||||||
|
{
|
||||||
|
public async Task<SyncHistoryEntry> SyncAsync(Device device, DateTime fromLocal, DateTime toLocal, bool useLastSyncCursor,
|
||||||
|
IProgress<AttendanceSyncProgress>? progress, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entry = new SyncHistoryEntry
|
||||||
|
{
|
||||||
|
Operation = SyncOperationKind.AttendanceSync,
|
||||||
|
SourceDevice = device.DisplayName,
|
||||||
|
Context = $"{fromLocal:dd-MMM-yyyy HH:mm} → {toLocal:dd-MMM-yyyy HH:mm}",
|
||||||
|
Status = "Running"
|
||||||
|
};
|
||||||
|
await history.AddAsync(entry, cancellationToken);
|
||||||
|
|
||||||
|
var fetched = 0;
|
||||||
|
var inserted = 0;
|
||||||
|
var skipped = 0;
|
||||||
|
var failed = 0;
|
||||||
|
DateTime? maxEventTime = null;
|
||||||
|
var allInsertsSucceeded = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (useLastSyncCursor)
|
||||||
|
{
|
||||||
|
var cursor = await machineRepository.GetLastSyncDateAsync(device.IpAddress, cancellationToken);
|
||||||
|
if (cursor.HasValue && cursor.Value > fromLocal)
|
||||||
|
fromLocal = cursor.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress?.Report(new AttendanceSyncProgress(0, 0, 0, 0, "Fetching attendance records from device…"));
|
||||||
|
var punches = await hikvision.FetchAcsEventsAsync(device, fromLocal, toLocal, cancellationToken);
|
||||||
|
fetched = punches.Count;
|
||||||
|
|
||||||
|
for (var index = 0; index < punches.Count; index++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var punch = punches[index];
|
||||||
|
progress?.Report(new AttendanceSyncProgress(fetched, inserted, skipped, failed,
|
||||||
|
$"Processing {index + 1}/{punches.Count}: {punch.EmployeeNo}"));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (await attendanceLog.ExistsAsync(punch.EmployeeNo, punch.CheckTime, device.MachineId, cancellationToken))
|
||||||
|
{
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await attendanceLog.InsertAsync(punch.EmployeeNo, punch.CheckTime, device.MachineId, punch.InOutTypeId, device.IpAddress, cancellationToken);
|
||||||
|
inserted++;
|
||||||
|
if (!maxEventTime.HasValue || punch.CheckTime > maxEventTime.Value)
|
||||||
|
maxEventTime = punch.CheckTime;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
failed++;
|
||||||
|
allInsertsSucceeded = false;
|
||||||
|
AppLogger.Warning($"Attendance insert failed for {punch.EmployeeNo}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allInsertsSucceeded)
|
||||||
|
{
|
||||||
|
var cursorTime = maxEventTime ?? toLocal;
|
||||||
|
await machineRepository.UpdateLastSyncDateAsync(device.IpAddress, cursorTime, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.CompletedAt = DateTime.Now;
|
||||||
|
entry.Total = fetched;
|
||||||
|
entry.Success = inserted;
|
||||||
|
entry.Skipped = skipped;
|
||||||
|
entry.Failed = failed;
|
||||||
|
entry.Status = failed > 0 ? "Completed with errors" : "Completed";
|
||||||
|
await history.UpdateAsync(entry, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
entry.CompletedAt = DateTime.Now;
|
||||||
|
entry.Total = fetched;
|
||||||
|
entry.Success = inserted;
|
||||||
|
entry.Skipped = skipped;
|
||||||
|
entry.Failed = failed + 1;
|
||||||
|
entry.Status = "Failed";
|
||||||
|
entry.Context += " — " + ex.Message;
|
||||||
|
await history.UpdateAsync(entry, cancellationToken);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
using HikvisionAttendanceManager.App.Models;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
/// <summary>Live dashboard probes that mutate the shared Device collection in place.</summary>
|
||||||
|
public sealed class DashboardService
|
||||||
|
{
|
||||||
|
public const int MaxConcurrentDeviceChecks = 4;
|
||||||
|
|
||||||
|
private readonly DeviceConnectionService _connectionService = new();
|
||||||
|
private readonly HikvisionIsapiClient _isapi = new();
|
||||||
|
private readonly OperationHistoryService _historyService = new();
|
||||||
|
|
||||||
|
public static void NormalizeConnectivityStatus(IEnumerable<Device> devices)
|
||||||
|
{
|
||||||
|
foreach (var device in devices)
|
||||||
|
{
|
||||||
|
if (!string.Equals(device.Status, "Online", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!string.Equals(device.Status, "Offline", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
device.Status = "Not Tested";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ProbeConnectivityAsync(IReadOnlyList<Device> devices, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (devices.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await Parallel.ForEachAsync(
|
||||||
|
devices,
|
||||||
|
new ParallelOptions { MaxDegreeOfParallelism = MaxConcurrentDeviceChecks, CancellationToken = cancellationToken },
|
||||||
|
async (device, token) =>
|
||||||
|
{
|
||||||
|
await ProbeDeviceAsync(device, token).ConfigureAwait(false);
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<SyncHistoryEntry>> LoadRecentHistoryAsync(CancellationToken cancellationToken = default) =>
|
||||||
|
await _historyService.LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
public static DateTime? GetLatestHrmsSync(IEnumerable<Device> devices) =>
|
||||||
|
devices
|
||||||
|
.Where(d => string.Equals(d.Source, "HRMS", StringComparison.OrdinalIgnoreCase) && d.LastSync.HasValue)
|
||||||
|
.Select(d => d.LastSync!.Value)
|
||||||
|
.DefaultIfEmpty()
|
||||||
|
.Max() is var latest && latest != default
|
||||||
|
? latest
|
||||||
|
: null;
|
||||||
|
|
||||||
|
public static async Task<string?> DetectHrmsErrorAsync(IReadOnlyList<Device> devices, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (devices.Any(d => string.Equals(d.Source, "HRMS", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (!HrmsConnectionFactory.TryGetConnectionString(out _))
|
||||||
|
return "Unable to load device data.\nCheck the HRMS connection.";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var connection = HrmsConnectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Error("Dashboard: HRMS connection check failed.", ex);
|
||||||
|
return "Unable to load device data.\nCheck the HRMS connection.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProbeDeviceAsync(Device device, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!HasCredentials(device))
|
||||||
|
{
|
||||||
|
device.Status = "Not Tested";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _connectionService.TestConnectivityAsync(device, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!result.IsConnected)
|
||||||
|
{
|
||||||
|
device.Status = "Offline";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
device.Status = "Online";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var liveCount = await _isapi.TryGetUserCountAsync(device, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (liveCount.HasValue)
|
||||||
|
device.RegisteredUserCount = liveCount.Value;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warning($"Dashboard: live user count failed for {device.Name}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Error($"Dashboard: connectivity probe failed for {device.Name}.", ex);
|
||||||
|
device.Status = "Offline";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasCredentials(Device device)
|
||||||
|
{
|
||||||
|
var username = device.Username ?? Environment.GetEnvironmentVariable("HIKVISION_MANAGER_DEFAULT_USERNAME") ?? "";
|
||||||
|
var password = !string.IsNullOrWhiteSpace(device.ProtectedPassword)
|
||||||
|
? PasswordProtector.Unprotect(device.ProtectedPassword)
|
||||||
|
: Environment.GetEnvironmentVariable("HIKVISION_MANAGER_DEFAULT_PASSWORD") ?? "";
|
||||||
|
return !string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
using HikvisionAttendanceManager.App.Models;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
public sealed class DepartmentalUserSyncService(
|
||||||
|
UserSyncEngine engine,
|
||||||
|
OperationHistoryService history)
|
||||||
|
{
|
||||||
|
public async Task<(SyncHistoryEntry Entry, IReadOnlyList<EmployeeSyncResult> Results)> SyncAsync(
|
||||||
|
Device device, IEnumerable<HrmsEmployee> employees, string contextSummary,
|
||||||
|
IProgress<UserSyncProgress>? progress, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var list = employees.ToList();
|
||||||
|
var entry = new SyncHistoryEntry
|
||||||
|
{
|
||||||
|
Operation = SyncOperationKind.DepartmentalUserSync,
|
||||||
|
TargetDevice = device.DisplayName,
|
||||||
|
Context = contextSummary,
|
||||||
|
Total = list.Count,
|
||||||
|
Status = "Running"
|
||||||
|
};
|
||||||
|
await history.AddAsync(entry, cancellationToken);
|
||||||
|
|
||||||
|
var results = await engine.RunAsync(device, list, initialEnrollment: true, progress, cancellationToken);
|
||||||
|
entry.CompletedAt = DateTime.Now;
|
||||||
|
entry.Success = results.Count(r => r.OverallResult is "SUCCESS" or "ALREADY_EXISTS");
|
||||||
|
entry.Failed = results.Count(r => r.OverallResult == "FAILED");
|
||||||
|
entry.Skipped = results.Count(r => r.OverallResult == "SKIPPED");
|
||||||
|
entry.Status = entry.Failed > 0 ? "Completed with errors" : "Completed";
|
||||||
|
await history.UpdateAsync(entry, cancellationToken);
|
||||||
|
return (entry, results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
using HikvisionAttendanceManager.App.Models;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
public sealed class DeviceConnectionService
|
||||||
|
{
|
||||||
|
public const int TestTimeoutSeconds = 10;
|
||||||
|
|
||||||
|
private readonly HikvisionIsapiClient _isapi = new();
|
||||||
|
private readonly SemaphoreSlim _testLock = new(1, 1);
|
||||||
|
|
||||||
|
public async Task<ConnectionResult> TestAsync(Device device, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!await _testLock.WaitAsync(0, cancellationToken).ConfigureAwait(false))
|
||||||
|
return ConnectionResult.Failed("A connection test is already in progress. Please wait.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await TestConnectivityCoreAsync(device, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_testLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lightweight connectivity test for batch/dashboard use (no single-flight lock).</summary>
|
||||||
|
public Task<ConnectionResult> TestConnectivityAsync(Device device, CancellationToken cancellationToken = default) =>
|
||||||
|
TestConnectivityCoreAsync(device, cancellationToken);
|
||||||
|
|
||||||
|
private async Task<ConnectionResult> TestConnectivityCoreAsync(Device device, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var deviceLabel = string.IsNullOrWhiteSpace(device.Name) ? device.IpAddress : device.Name;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
AppLogger.Info($"[DEVICE_TEST] Started device={deviceLabel} ip={device.IpAddress}");
|
||||||
|
AppLogger.Info($"[DEVICE_TEST] Connecting ip={device.IpAddress} isapiPort={device.IsapiPort}");
|
||||||
|
|
||||||
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeout.CancelAfter(TimeSpan.FromSeconds(TestTimeoutSeconds));
|
||||||
|
|
||||||
|
var result = await _isapi.TestConnectionAsync(device, timeout.Token).ConfigureAwait(false);
|
||||||
|
if (result.Success)
|
||||||
|
{
|
||||||
|
AppLogger.Info($"[DEVICE_TEST] Connected device={deviceLabel} ip={device.IpAddress} model={result.Model ?? "-"} firmware={result.Firmware ?? "-"}");
|
||||||
|
return ConnectionResult.Connected(FormatSuccessMessage(device, result), result.Model, result.Firmware);
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Warning($"[DEVICE_TEST] Failed device={deviceLabel} ip={device.IpAddress} reason={result.Reason}");
|
||||||
|
return ConnectionResult.Failed(FormatFailureMessage(result.Reason));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
AppLogger.Warning($"[DEVICE_TEST] Timeout device={deviceLabel} ip={device.IpAddress}");
|
||||||
|
return ConnectionResult.Failed($"Device did not respond within {TestTimeoutSeconds} seconds.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Error($"[DEVICE_TEST] Failed device={deviceLabel} ip={device.IpAddress} reason={ex.Message}", ex);
|
||||||
|
return ConnectionResult.Failed(FriendlyFailure(ex));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
AppLogger.Info($"[DEVICE_TEST] Finished device={deviceLabel}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatSuccessMessage(Device device, IsapiTestResult result)
|
||||||
|
{
|
||||||
|
var lines = new List<string> { "Connection successful", result.DeviceName ?? device.Name, device.IpAddress };
|
||||||
|
if (!string.IsNullOrWhiteSpace(result.Model)) lines.Add($"Model: {result.Model}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(result.Firmware)) lines.Add($"Firmware: {result.Firmware}");
|
||||||
|
return string.Join("\n", lines.Where(line => !string.IsNullOrWhiteSpace(line)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatFailureMessage(string reason) => string.IsNullOrWhiteSpace(reason) ? "Connection failed." : reason;
|
||||||
|
|
||||||
|
private static string FriendlyFailure(Exception ex) =>
|
||||||
|
ex is InvalidOperationException ? ex.Message : "Unexpected connection error. See the application log for details.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ConnectionResult(bool IsConnected, string Message, string? Model = null, string? Firmware = null)
|
||||||
|
{
|
||||||
|
public static ConnectionResult Connected(string message, string? model = null, string? firmware = null) => new(true, message, model, firmware);
|
||||||
|
public static ConnectionResult Failed(string message) => new(false, message);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using HikvisionAttendanceManager.App.Models;
|
||||||
|
using MySql.Data.MySqlClient;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
/// <summary>Provides one de-duplicated device list: HRMS machines first, plus Manager-only manual machines.</summary>
|
||||||
|
public sealed class DeviceService
|
||||||
|
{
|
||||||
|
private readonly DeviceStore _store = new();
|
||||||
|
|
||||||
|
public async Task<ObservableCollection<Device>> LoadUnifiedAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var manual = await _store.LoadAsync();
|
||||||
|
AppLogger.Info($"Devices: loaded {manual.Count} manual device(s).");
|
||||||
|
foreach (var item in manual) item.Source = "Manual";
|
||||||
|
var hrms = await LoadHrmsDevicesAsync(cancellationToken);
|
||||||
|
AppLogger.Info($"Devices: HRMS returned {hrms.Count} Hikvision device(s).");
|
||||||
|
var merged = hrms.ToList();
|
||||||
|
foreach (var device in manual)
|
||||||
|
{
|
||||||
|
if (!merged.Any(existing => SameDevice(existing, device))) merged.Add(device);
|
||||||
|
}
|
||||||
|
return new ObservableCollection<Device>(merged.OrderBy(d => d.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveManualAsync(Device device, IEnumerable<Device> unifiedDevices)
|
||||||
|
{
|
||||||
|
if (unifiedDevices.Any(existing => !ReferenceEquals(existing, device) && SameDevice(existing, device)))
|
||||||
|
throw new InvalidOperationException("This Hikvision device already exists.");
|
||||||
|
|
||||||
|
var manual = await _store.LoadAsync();
|
||||||
|
if (manual.Any(existing => !ReferenceEquals(existing, device) && SameDevice(existing, device)))
|
||||||
|
throw new InvalidOperationException("This Hikvision device already exists.");
|
||||||
|
device.Source = "Manual";
|
||||||
|
var updated = manual.Where(d => d.Id != device.Id).Append(device).ToList();
|
||||||
|
await _store.SaveAsync(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveManualAsync(Device device)
|
||||||
|
{
|
||||||
|
if (!string.Equals(device.Source, "Manual", StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new InvalidOperationException("HRMS devices must be managed in HRMS.");
|
||||||
|
var manual = await _store.LoadAsync();
|
||||||
|
await _store.SaveAsync(manual.Where(d => d.Id != device.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SameDevice(Device left, Device right) =>
|
||||||
|
(!string.IsNullOrWhiteSpace(left.MachineId) && string.Equals(left.MachineId.Trim(), right.MachineId.Trim(), StringComparison.OrdinalIgnoreCase)) ||
|
||||||
|
(!string.IsNullOrWhiteSpace(left.IpAddress) && string.Equals(left.IpAddress.Trim(), right.IpAddress.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
private static async Task<IReadOnlyList<Device>> LoadHrmsDevicesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!HrmsConnectionFactory.TryGetConnectionString(out _))
|
||||||
|
{
|
||||||
|
AppLogger.Warning("Devices: HRMS query skipped because no connection configuration was resolved.");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const string sql = """
|
||||||
|
SELECT machine_id,machine_ip,port_number,machine_name,machine_status,machine_type,status,last_sync_date,total_users
|
||||||
|
FROM hrms.attendance_machine
|
||||||
|
WHERE (machine_status='active' OR machine_status='1' OR machine_status=1)
|
||||||
|
AND UPPER(TRIM(machine_type))=UPPER(TRIM('HIKVISION'))
|
||||||
|
""";
|
||||||
|
var devices = new List<Device>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var connection = HrmsConnectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
AppLogger.Info("Devices: HRMS MySQL connection opened.");
|
||||||
|
await using var command = new MySqlCommand(sql, connection);
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
devices.Add(new Device
|
||||||
|
{
|
||||||
|
Name = reader["machine_name"]?.ToString() ?? "",
|
||||||
|
MachineId = reader["machine_id"]?.ToString() ?? "",
|
||||||
|
IpAddress = reader["machine_ip"]?.ToString() ?? "",
|
||||||
|
Port = reader["port_number"] == DBNull.Value ? 8000 : Convert.ToInt32(reader["port_number"]),
|
||||||
|
Model = reader["machine_type"]?.ToString() ?? "HIKVISION",
|
||||||
|
Source = "HRMS",
|
||||||
|
Status = "Unknown",
|
||||||
|
LastSync = reader["last_sync_date"] == DBNull.Value ? null : Convert.ToDateTime(reader["last_sync_date"]),
|
||||||
|
RegisteredUserCount = reader["total_users"] == DBNull.Value ? null : Convert.ToInt32(reader["total_users"])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Error("Devices: HRMS MySQL connection or query failed.", ex);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using HikvisionAttendanceManager.App.Models;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
public sealed class DeviceStore
|
||||||
|
{
|
||||||
|
private readonly string _path = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"Utopia", "HikvisionAttendanceManager", "devices.json");
|
||||||
|
|
||||||
|
public async Task<ObservableCollection<Device>> LoadAsync()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_path))
|
||||||
|
{
|
||||||
|
return new ObservableCollection<Device>();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var stream = File.OpenRead(_path);
|
||||||
|
var devices = await JsonSerializer.DeserializeAsync<List<Device>>(stream) ?? [];
|
||||||
|
return new ObservableCollection<Device>(devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveAsync(IEnumerable<Device> devices)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
|
||||||
|
await using var stream = File.Create(_path);
|
||||||
|
await JsonSerializer.SerializeAsync(stream, devices, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
using HikvisionAttendanceManager.App.Models;
|
||||||
|
using System.Net.Http;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
public sealed class EmployeePhotoService
|
||||||
|
{
|
||||||
|
private const string BaseUrl = "https://portal.utopiaindustries.pk/uind/employee-photo";
|
||||||
|
private readonly HttpClient _client = new() { Timeout = TimeSpan.FromSeconds(30) };
|
||||||
|
|
||||||
|
public Uri GetPhotoUri(HrmsEmployee employee) => new($"{BaseUrl}/{employee.Id}.jpeg");
|
||||||
|
|
||||||
|
public async Task<ApiResultWithBytes> DownloadAndNormalizeAsync(HrmsEmployee employee, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _client.GetAsync(GetPhotoUri(employee), cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return ApiResultWithBytes.Failed($"Photo not found or unavailable (HTTP {(int)response.StatusCode}).");
|
||||||
|
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||||
|
if (bytes.Length < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8 || bytes[2] != 0xFF)
|
||||||
|
return ApiResultWithBytes.Failed("Face processing failed: the employee photo is not a JPEG.");
|
||||||
|
if (bytes.Length > 2 * 1024 * 1024)
|
||||||
|
return ApiResultWithBytes.Failed("Face processing failed: photo exceeds the 2 MB safety limit.");
|
||||||
|
return ApiResultWithBytes.Succeeded(bytes);
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return ApiResultWithBytes.Failed("Photo download timed out.");
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return ApiResultWithBytes.Failed("Photo download failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ApiResultWithBytes(bool Success, string Reason, byte[] Bytes)
|
||||||
|
{
|
||||||
|
public static ApiResultWithBytes Succeeded(byte[] bytes) => new(true, "", bytes);
|
||||||
|
public static ApiResultWithBytes Failed(string reason) => new(false, reason, []);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,529 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using HikvisionAttendanceManager.App.Models;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
/// <summary>Direct ISAPI client. It does not use or communicate with HikvisionAttendanceService.</summary>
|
||||||
|
public sealed class HikvisionIsapiClient
|
||||||
|
{
|
||||||
|
private const string FaceLibraryType = "blackFD";
|
||||||
|
private const string FaceLibraryId = "1";
|
||||||
|
|
||||||
|
public async Task<IsapiTestResult> TestConnectionAsync(Device device, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!TryGetCredentials(device, out var username, out var password, out var credentialError))
|
||||||
|
return IsapiTestResult.Failed(credentialError);
|
||||||
|
|
||||||
|
using var client = CreateTestClient(device, username, password);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await client.GetAsync("/ISAPI/System/deviceInfo?format=json", cancellationToken).ConfigureAwait(false);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
|
return IsapiTestResult.Failed("Invalid credentials (ISAPI authentication failed).");
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return IsapiTestResult.Failed($"ISAPI request failed (HTTP {(int)response.StatusCode}).");
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body);
|
||||||
|
var deviceName = FindStringProperty(document.RootElement, "deviceName", "DeviceName") ?? device.Name;
|
||||||
|
var model = FindStringProperty(document.RootElement, "model", "deviceType", "Model");
|
||||||
|
var firmware = FindStringProperty(document.RootElement, "firmwareVersion", "firmwareReleasedDate", "FirmwareVersion");
|
||||||
|
return IsapiTestResult.Succeeded(deviceName, model, firmware);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
return IsapiTestResult.Failed("Device unreachable. Check the IP address and network connection.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetCredentials(Device device, out string username, out string password, out string error)
|
||||||
|
{
|
||||||
|
username = device.Username ?? Environment.GetEnvironmentVariable("HIKVISION_MANAGER_DEFAULT_USERNAME") ?? "";
|
||||||
|
password = !string.IsNullOrWhiteSpace(device.ProtectedPassword)
|
||||||
|
? PasswordProtector.Unprotect(device.ProtectedPassword)
|
||||||
|
: Environment.GetEnvironmentVariable("HIKVISION_MANAGER_DEFAULT_PASSWORD") ?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||||
|
{
|
||||||
|
error = "Hikvision credentials are not configured for this device.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
error = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpClient CreateTestClient(Device device, string username, string password) =>
|
||||||
|
new(new SocketsHttpHandler
|
||||||
|
{
|
||||||
|
Credentials = new NetworkCredential(username, password),
|
||||||
|
PreAuthenticate = false,
|
||||||
|
ConnectTimeout = TimeSpan.FromSeconds(10),
|
||||||
|
PooledConnectionLifetime = TimeSpan.Zero
|
||||||
|
})
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri($"http://{device.IpAddress}:{device.IsapiPort}"),
|
||||||
|
Timeout = TimeSpan.FromSeconds(10)
|
||||||
|
};
|
||||||
|
|
||||||
|
private static HttpClient CreateClient(Device device)
|
||||||
|
{
|
||||||
|
if (!TryGetCredentials(device, out var username, out var password, out var error))
|
||||||
|
throw new InvalidOperationException(error);
|
||||||
|
|
||||||
|
return new HttpClient(new HttpClientHandler
|
||||||
|
{
|
||||||
|
Credentials = new NetworkCredential(username, password),
|
||||||
|
PreAuthenticate = false,
|
||||||
|
UseDefaultCredentials = false
|
||||||
|
}) { BaseAddress = new Uri($"http://{device.IpAddress}:{device.IsapiPort}"), Timeout = TimeSpan.FromSeconds(90) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> UserExistsAsync(Device device, string employeeNo, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var client = CreateClient(device);
|
||||||
|
var json = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
UserInfoSearchCond = new
|
||||||
|
{
|
||||||
|
searchID = "1",
|
||||||
|
searchResultPosition = 0,
|
||||||
|
maxResults = 10,
|
||||||
|
EmployeeNoList = new[] { new { employeeNo } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Search?format=json", json, cancellationToken);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode && body.Contains(employeeNo, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApiResult> CreateUserAsync(Device device, HrmsEmployee employee, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var client = CreateClient(device);
|
||||||
|
var payload = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
UserInfo = new
|
||||||
|
{
|
||||||
|
employeeNo = employee.SerialNumber,
|
||||||
|
name = string.IsNullOrWhiteSpace(employee.Name) ? employee.SerialNumber : employee.Name,
|
||||||
|
userType = "normal",
|
||||||
|
Valid = new { enable = true, beginTime = "2000-01-01T00:00:00", endTime = "2037-12-31T23:59:59" },
|
||||||
|
doorRight = "1",
|
||||||
|
RightPlan = new[] { new { doorNo = 1, planTemplateNo = "1" } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Record?format=json", payload, cancellationToken);
|
||||||
|
return await ToResultAsync(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lightweight user count via UserInfo Search totalMatches (maxResults=1).</summary>
|
||||||
|
public async Task<int?> TryGetUserCountAsync(Device device, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!TryGetCredentials(device, out var username, out var password, out _))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
using var client = CreateTestClient(device, username, password);
|
||||||
|
var payload = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
UserInfoSearchCond = new
|
||||||
|
{
|
||||||
|
searchID = "1",
|
||||||
|
searchResultPosition = 0,
|
||||||
|
maxResults = 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Search?format=json", payload, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
|
||||||
|
var total = FindIntProperty(document.RootElement, "totalMatches", "numOfMatches", "totalMatch");
|
||||||
|
return total >= 0 ? total : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApiResult> CreateUserAsync(Device device, string employeeNo, string name, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var client = CreateClient(device);
|
||||||
|
var payload = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
UserInfo = new
|
||||||
|
{
|
||||||
|
employeeNo,
|
||||||
|
name = string.IsNullOrWhiteSpace(name) ? employeeNo : name,
|
||||||
|
userType = "normal",
|
||||||
|
Valid = new { enable = true, beginTime = "2000-01-01T00:00:00", endTime = "2037-12-31T23:59:59" },
|
||||||
|
doorRight = "1",
|
||||||
|
RightPlan = new[] { new { doorNo = 1, planTemplateNo = "1" } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Record?format=json", payload, cancellationToken);
|
||||||
|
return await ToResultAsync(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApiResult> UploadFaceAsync(Device device, string employeeNo, byte[] jpeg, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!IsJpeg(jpeg)) return ApiResult.Failed("Face processing failed: photo is not a valid JPEG.");
|
||||||
|
using var client = CreateClient(device);
|
||||||
|
var boundary = "---------------" + Guid.NewGuid().ToString("N");
|
||||||
|
var metadata = $$"""{"faceLibType":"{{FaceLibraryType}}","FDID":"{{FaceLibraryId}}","FPID":"{{employeeNo}}"}""";
|
||||||
|
var body = BuildFaceMultipart(boundary, metadata, jpeg);
|
||||||
|
using var content = new ByteArrayContent(body);
|
||||||
|
content.Headers.TryAddWithoutValidation("Content-Type", $"multipart/form-data; boundary={boundary}");
|
||||||
|
using var response = await client.PostAsync("/ISAPI/Intelligent/FDLib/FaceDataRecord?format=json", content, cancellationToken);
|
||||||
|
return await ToResultAsync(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApiResult> VerifyFaceAsync(Device device, string employeeNo, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var client = CreateClient(device);
|
||||||
|
var payload = $$"""{"searchID":"1","searchResultPosition":0,"maxResults":10,"faceLibType":"{{FaceLibraryType}}","FDID":"{{FaceLibraryId}}","FPID":"{{employeeNo}}","gender":"any","certificateType":"ID"}""";
|
||||||
|
using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/Intelligent/FDLib/FDSearch?format=json", payload, cancellationToken);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode) return ApiResult.Failed(ExtractReason(body, $"HTTP {(int)response.StatusCode}"));
|
||||||
|
return body.Contains(employeeNo, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? ApiResult.Succeeded()
|
||||||
|
: ApiResult.Failed("Face enrollment could not be verified by this device.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApiResult> DeleteUsersAsync(Device device, IReadOnlyList<string> employeeNumbers, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (employeeNumbers.Count == 0) return ApiResult.Succeeded();
|
||||||
|
using var client = CreateClient(device);
|
||||||
|
const int batchSize = 30;
|
||||||
|
for (var offset = 0; offset < employeeNumbers.Count; offset += batchSize)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var slice = employeeNumbers.Skip(offset).Take(batchSize).Select(e => new { employeeNo = e }).ToArray();
|
||||||
|
var payload = JsonSerializer.Serialize(new { UserInfoDelCond = new { EmployeeNoList = slice } });
|
||||||
|
using var response = await SendJsonAsync(client, HttpMethod.Put, "/ISAPI/AccessControl/UserInfo/Delete?format=json", payload, cancellationToken);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode || !IsSuccessfulIsapiBody(body))
|
||||||
|
{
|
||||||
|
var fallback = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
UserInfoDetail = new { mode = "byEmployeeNo", EmployeeNoList = slice }
|
||||||
|
});
|
||||||
|
using var fallbackResponse = await SendJsonAsync(client, HttpMethod.Put,
|
||||||
|
"/ISAPI/AccessControl/UserInfoDetail/Delete?format=json", fallback, cancellationToken);
|
||||||
|
var fallbackBody = await fallbackResponse.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
if (!fallbackResponse.IsSuccessStatusCode || !IsSuccessfulIsapiBody(fallbackBody))
|
||||||
|
return ApiResult.Failed(ExtractReason(fallbackBody, ExtractReason(body, $"HTTP {(int)fallbackResponse.StatusCode}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ApiResult.Succeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<HikvisionUser>> GetUsersAsync(Device device, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var client = CreateClient(device);
|
||||||
|
var users = new List<HikvisionUser>();
|
||||||
|
const int pageSize = 100;
|
||||||
|
for (var position = 0; ; position += pageSize)
|
||||||
|
{
|
||||||
|
var payload = JsonSerializer.Serialize(new { UserInfoSearchCond = new { searchID = "1", searchResultPosition = position, maxResults = pageSize } });
|
||||||
|
using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Search?format=json", payload, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode) throw new InvalidOperationException($"Unable to read device users (HTTP {(int)response.StatusCode}).");
|
||||||
|
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
|
||||||
|
var page = FindUsers(document.RootElement);
|
||||||
|
users.AddRange(page);
|
||||||
|
if (page.Count < pageSize) break;
|
||||||
|
}
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<AttendancePunch>> FetchAcsEventsAsync(Device device, DateTime fromLocal, DateTime toLocal, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var client = CreateClient(device);
|
||||||
|
const uint major = 0;
|
||||||
|
const uint minor = 0;
|
||||||
|
var searchId = "1";
|
||||||
|
var startTime = fromLocal.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture) + "+05:00";
|
||||||
|
var endTime = toLocal.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture) + "+05:00";
|
||||||
|
var punches = new List<AttendancePunch>();
|
||||||
|
var searchResultPosition = 0;
|
||||||
|
var maxResults = 30;
|
||||||
|
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var payload = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
AcsEventCond = new
|
||||||
|
{
|
||||||
|
searchID = searchId,
|
||||||
|
searchResultPosition,
|
||||||
|
maxResults,
|
||||||
|
major,
|
||||||
|
minor,
|
||||||
|
startTime,
|
||||||
|
endTime
|
||||||
|
}
|
||||||
|
});
|
||||||
|
using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/AcsEvent?format=json", payload, cancellationToken);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
throw new InvalidOperationException($"ACS event fetch failed (HTTP {(int)response.StatusCode}).");
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(body);
|
||||||
|
var status = FindStringProperty(document.RootElement, "responseStatusStrg", "responseStatusStr", "responseStatusString");
|
||||||
|
var events = ExtractAcsEvents(document.RootElement);
|
||||||
|
foreach (var ev in events)
|
||||||
|
{
|
||||||
|
if (TryParseAttendancePunch(ev, out var punch))
|
||||||
|
punches.Add(punch);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (events.Count < maxResults || string.Equals(status, "END", StringComparison.OrdinalIgnoreCase) || string.Equals(status, "NO MATCH", StringComparison.OrdinalIgnoreCase))
|
||||||
|
break;
|
||||||
|
|
||||||
|
searchResultPosition += events.Count;
|
||||||
|
if (searchResultPosition > 0 && searchResultPosition % 90 == 0) maxResults = Math.Min(100, maxResults + 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
return punches;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApiResultWithBytes> DownloadFaceTemplateAsync(Device device, string employeeNo, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var client = CreateClient(device);
|
||||||
|
var payload = $$"""{"searchID":"1","searchResultPosition":0,"maxResults":10,"faceLibType":"{{FaceLibraryType}}","FDID":"{{FaceLibraryId}}","FPID":"{{employeeNo}}","gender":"any","certificateType":"ID"}""";
|
||||||
|
using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/Intelligent/FDLib/FDSearch?format=json", payload, cancellationToken);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return ApiResultWithBytes.Failed($"FDSearch failed (HTTP {(int)response.StatusCode}).");
|
||||||
|
|
||||||
|
var faceUrl = FindStringProperty(JsonDocument.Parse(body).RootElement, "faceURL", "faceUrl", "pictureURL", "pictureUrl");
|
||||||
|
if (string.IsNullOrWhiteSpace(faceUrl))
|
||||||
|
return ApiResultWithBytes.Failed("No face template URL returned by device.");
|
||||||
|
|
||||||
|
var path = faceUrl;
|
||||||
|
if (Uri.TryCreate(faceUrl, UriKind.Absolute, out var absolute))
|
||||||
|
path = absolute.PathAndQuery;
|
||||||
|
|
||||||
|
using var imageResponse = await client.GetAsync(path, cancellationToken);
|
||||||
|
if (!imageResponse.IsSuccessStatusCode)
|
||||||
|
return ApiResultWithBytes.Failed($"Face image download failed (HTTP {(int)imageResponse.StatusCode}).");
|
||||||
|
|
||||||
|
var bytes = await imageResponse.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||||
|
return IsJpeg(bytes)
|
||||||
|
? ApiResultWithBytes.Succeeded(bytes)
|
||||||
|
: ApiResultWithBytes.Failed("Downloaded face data is not a JPEG template.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsJpeg(byte[] bytes) => bytes.Length > 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
|
||||||
|
|
||||||
|
public static bool IsUploadableFaceTemplate(byte[] bytes) => IsJpeg(bytes);
|
||||||
|
|
||||||
|
private static List<HikvisionUser> FindUsers(JsonElement element)
|
||||||
|
{
|
||||||
|
if (element.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
if (element.TryGetProperty("UserInfo", out var users) && users.ValueKind == JsonValueKind.Array)
|
||||||
|
return users.EnumerateArray().Select(item => new HikvisionUser(
|
||||||
|
item.TryGetProperty("employeeNo", out var employeeNo) ? employeeNo.ToString() : "",
|
||||||
|
item.TryGetProperty("name", out var name) ? name.ToString() : "",
|
||||||
|
item.TryGetProperty("numOfFace", out var faces) && faces.TryGetInt32(out var count) ? count : 0))
|
||||||
|
.Where(user => !string.IsNullOrWhiteSpace(user.EmployeeNo)).ToList();
|
||||||
|
foreach (var property in element.EnumerateObject())
|
||||||
|
{
|
||||||
|
var found = FindUsers(property.Value);
|
||||||
|
if (found.Count > 0) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (element.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var item in element.EnumerateArray())
|
||||||
|
{
|
||||||
|
var found = FindUsers(item);
|
||||||
|
if (found.Count > 0) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<JsonElement> ExtractAcsEvents(JsonElement element)
|
||||||
|
{
|
||||||
|
var results = new List<JsonElement>();
|
||||||
|
if (element.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
if (element.TryGetProperty("AcsEvent", out var acs) && acs.TryGetProperty("InfoList", out var infoList) && infoList.ValueKind == JsonValueKind.Array)
|
||||||
|
results.AddRange(infoList.EnumerateArray());
|
||||||
|
foreach (var property in element.EnumerateObject())
|
||||||
|
results.AddRange(ExtractAcsEvents(property.Value));
|
||||||
|
}
|
||||||
|
else if (element.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var item in element.EnumerateArray())
|
||||||
|
results.AddRange(ExtractAcsEvents(item));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseAttendancePunch(JsonElement info, out AttendancePunch punch)
|
||||||
|
{
|
||||||
|
punch = default!;
|
||||||
|
var employeeNo = FindStringProperty(info, "employeeNoString", "employeeNo", "employeeNoStr");
|
||||||
|
if (string.IsNullOrWhiteSpace(employeeNo) || !int.TryParse(employeeNo.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!TryExtractEventTime(info, out var checkTime))
|
||||||
|
checkTime = DateTime.Now;
|
||||||
|
|
||||||
|
var minor = info.TryGetProperty("minor", out var minorProp) && minorProp.TryGetUInt32(out var minorValue)
|
||||||
|
? (int)minorValue
|
||||||
|
: 0;
|
||||||
|
punch = new AttendancePunch(employeeNo.Trim(), checkTime, minor);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryExtractEventTime(JsonElement info, out DateTime dt)
|
||||||
|
{
|
||||||
|
foreach (var key in new[] { "time", "eventTime", "verifyTime", "statusTime", "attendanceTime" })
|
||||||
|
{
|
||||||
|
if (info.TryGetProperty(key, out var value) && TryParseDateTime(value, out dt))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var property in info.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (property.Name.Contains("time", StringComparison.OrdinalIgnoreCase) && TryParseDateTime(property.Value, out dt))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseDateTime(JsonElement value, out DateTime dt)
|
||||||
|
{
|
||||||
|
if (value.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
var text = value.GetString() ?? "";
|
||||||
|
if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out dt))
|
||||||
|
return true;
|
||||||
|
if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out dt))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
dt = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FindStringProperty(JsonElement element, params string[] keys)
|
||||||
|
{
|
||||||
|
if (element.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
foreach (var key in keys)
|
||||||
|
{
|
||||||
|
if (element.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
var text = value.GetString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(text)) return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (var property in element.EnumerateObject())
|
||||||
|
{
|
||||||
|
var found = FindStringProperty(property.Value, keys);
|
||||||
|
if (!string.IsNullOrWhiteSpace(found)) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (element.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var item in element.EnumerateArray())
|
||||||
|
{
|
||||||
|
var found = FindStringProperty(item, keys);
|
||||||
|
if (!string.IsNullOrWhiteSpace(found)) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int FindIntProperty(JsonElement element, params string[] keys)
|
||||||
|
{
|
||||||
|
if (element.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
foreach (var key in keys)
|
||||||
|
{
|
||||||
|
if (element.TryGetProperty(key, out var value))
|
||||||
|
{
|
||||||
|
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number))
|
||||||
|
return number;
|
||||||
|
if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out number))
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (var property in element.EnumerateObject())
|
||||||
|
{
|
||||||
|
var found = FindIntProperty(property.Value, keys);
|
||||||
|
if (found >= 0) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (element.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var item in element.EnumerateArray())
|
||||||
|
{
|
||||||
|
var found = FindIntProperty(item, keys);
|
||||||
|
if (found >= 0) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<HttpResponseMessage> SendJsonAsync(HttpClient client, HttpMethod method, string uri, string json, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(method, uri) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
|
||||||
|
return await client.SendAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ApiResult> ToResultAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode && IsSuccessfulIsapiBody(body)
|
||||||
|
? ApiResult.Succeeded()
|
||||||
|
: ApiResult.Failed(ExtractReason(body, $"HTTP {(int)response.StatusCode}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSuccessfulIsapiBody(string body) =>
|
||||||
|
string.IsNullOrWhiteSpace(body) || body.Contains("\"statusCode\":1") || body.Contains("\"statusString\":\"OK\"", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static string ExtractReason(string body, string fallback)
|
||||||
|
{
|
||||||
|
foreach (var key in new[] { "subStatusCode", "statusString", "errorMsg" })
|
||||||
|
{
|
||||||
|
var marker = $"\"{key}\"";
|
||||||
|
var index = body.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (index >= 0) return body.Substring(index, Math.Min(160, body.Length - index)).Replace("\r", " ").Replace("\n", " ");
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] BuildFaceMultipart(string boundary, string metadata, byte[] jpeg)
|
||||||
|
{
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
void Write(string text) { var bytes = Encoding.UTF8.GetBytes(text); stream.Write(bytes); }
|
||||||
|
var metadataBytes = Encoding.UTF8.GetBytes(metadata);
|
||||||
|
Write($"--{boundary}\r\nContent-Disposition: form-data; name=\"FaceDataRecord\";\r\nContent-Type: application/json\r\nContent-Length: {metadataBytes.Length}\r\n\r\n");
|
||||||
|
stream.Write(metadataBytes);
|
||||||
|
Write($"\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"FaceImage\";\r\nContent-Type: image/jpeg\r\nContent-Length: {jpeg.Length}\r\n\r\n");
|
||||||
|
stream.Write(jpeg);
|
||||||
|
Write($"\r\n--{boundary}--\r\n");
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record IsapiTestResult(bool Success, string Reason, string? DeviceName, string? Model, string? Firmware)
|
||||||
|
{
|
||||||
|
public static IsapiTestResult Succeeded(string? deviceName, string? model, string? firmware) =>
|
||||||
|
new(true, "", deviceName, model, firmware);
|
||||||
|
|
||||||
|
public static IsapiTestResult Failed(string reason) => new(false, reason, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ApiResult(bool Success, string Reason)
|
||||||
|
{
|
||||||
|
public static ApiResult Succeeded() => new(true, "");
|
||||||
|
public static ApiResult Failed(string reason) => new(false, reason);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
using MySql.Data.MySqlClient;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the same primary HRMS connection setting used by HikvisionAttendanceService.
|
||||||
|
/// The Manager-specific setting remains a temporary backward-compatible fallback.
|
||||||
|
/// </summary>
|
||||||
|
public static class HrmsConnectionFactory
|
||||||
|
{
|
||||||
|
public const string ServiceConnectionVariable = "HIKVISION_DB_CONNECTION_STRING";
|
||||||
|
public const string LegacyManagerConnectionVariable = "HIKVISION_MANAGER_HRMS_CONNECTION_STRING";
|
||||||
|
public const string ServiceHostVariable = "HIKVISION_DB_HOST";
|
||||||
|
public const string ServicePortVariable = "HIKVISION_DB_PORT";
|
||||||
|
public const string ServiceNameVariable = "HIKVISION_DB_NAME";
|
||||||
|
public const string ServiceUsernameVariable = "HIKVISION_DB_USERNAME";
|
||||||
|
public const string ServicePasswordVariable = "HIKVISION_DB_PASSWORD";
|
||||||
|
|
||||||
|
public static bool TryGetConnectionString(out string connectionString)
|
||||||
|
{
|
||||||
|
var environment = GetEnvironmentName();
|
||||||
|
var developmentConfigExists = File.Exists(Path.Combine(AppContext.BaseDirectory, "appsettings.Development.json"));
|
||||||
|
AppLogger.Info($"Environment = {environment}");
|
||||||
|
AppLogger.Info($"Development config loaded = {(IsDevelopment() && developmentConfigExists ? "YES" : "NO")}");
|
||||||
|
|
||||||
|
if (IsDevelopment() && TryGetDevelopmentConnectionString(out connectionString))
|
||||||
|
{
|
||||||
|
AppLogger.Info("HRMS connection configuration loaded from Development settings.");
|
||||||
|
AppLogger.Info("HRMS connection configuration found = YES");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionString = Environment.GetEnvironmentVariable(ServiceConnectionVariable)?.Trim() ?? "";
|
||||||
|
if (!string.IsNullOrWhiteSpace(connectionString))
|
||||||
|
{
|
||||||
|
AppLogger.Info("HRMS connection configuration loaded from the service environment variable.");
|
||||||
|
AppLogger.Info("HRMS connection configuration found = YES");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionString = Environment.GetEnvironmentVariable(LegacyManagerConnectionVariable)?.Trim() ?? "";
|
||||||
|
if (!string.IsNullOrWhiteSpace(connectionString))
|
||||||
|
{
|
||||||
|
AppLogger.Info("HRMS connection configuration loaded from the legacy Manager environment variable.");
|
||||||
|
AppLogger.Info("HRMS connection configuration found = YES");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolved = TryBuildFromServiceVariables(out connectionString);
|
||||||
|
if (resolved)
|
||||||
|
{
|
||||||
|
AppLogger.Info("HRMS connection configuration loaded from service database environment settings.");
|
||||||
|
AppLogger.Info("HRMS connection configuration found = YES");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AppLogger.Warning("HRMS connection configuration found = NO");
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MySqlConnection CreateConnection()
|
||||||
|
{
|
||||||
|
if (!TryGetConnectionString(out var connectionString))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"HRMS is not configured. Set {ServiceConnectionVariable} (preferred), the HIKVISION_DB_* local variables, or {LegacyManagerConnectionVariable} (legacy fallback).");
|
||||||
|
var builder = new MySqlConnectionStringBuilder(connectionString)
|
||||||
|
{
|
||||||
|
ConnectionTimeout = 12,
|
||||||
|
DefaultCommandTimeout = 15
|
||||||
|
};
|
||||||
|
return new MySqlConnection(builder.ConnectionString);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryBuildFromServiceVariables(out string connectionString)
|
||||||
|
{
|
||||||
|
connectionString = "";
|
||||||
|
|
||||||
|
var host = Environment.GetEnvironmentVariable(ServiceHostVariable)?.Trim() ?? "";
|
||||||
|
var username = Environment.GetEnvironmentVariable(ServiceUsernameVariable)?.Trim() ?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(username))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var dbName = Environment.GetEnvironmentVariable(ServiceNameVariable)?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(dbName))
|
||||||
|
dbName = "hrms";
|
||||||
|
|
||||||
|
var builder = new MySqlConnectionStringBuilder
|
||||||
|
{
|
||||||
|
Server = host,
|
||||||
|
Port = TryGetPort(),
|
||||||
|
Database = dbName,
|
||||||
|
UserID = username,
|
||||||
|
Password = Environment.GetEnvironmentVariable(ServicePasswordVariable) ?? "",
|
||||||
|
DefaultCommandTimeout = 30
|
||||||
|
};
|
||||||
|
|
||||||
|
connectionString = builder.ConnectionString;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint TryGetPort()
|
||||||
|
{
|
||||||
|
var rawPort = Environment.GetEnvironmentVariable(ServicePortVariable);
|
||||||
|
return uint.TryParse(rawPort, out var port) && port > 0 ? port : 3306;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDevelopment() =>
|
||||||
|
string.Equals(GetEnvironmentName(), "Development", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static string GetEnvironmentName()
|
||||||
|
{
|
||||||
|
var environment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT")?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(environment))
|
||||||
|
return environment;
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
// WPF does not supply an environment name when launched from Visual Studio.
|
||||||
|
// A Debug build is local development unless the caller explicitly supplied one.
|
||||||
|
return "Development";
|
||||||
|
#else
|
||||||
|
return "Production";
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetDevelopmentConnectionString(out string connectionString)
|
||||||
|
{
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.SetBasePath(AppContext.BaseDirectory)
|
||||||
|
.AddJsonFile("appsettings.json", optional: true)
|
||||||
|
.AddJsonFile("appsettings.Development.json", optional: true)
|
||||||
|
.Build();
|
||||||
|
connectionString = configuration.GetConnectionString("Hrms")?.Trim() ?? "";
|
||||||
|
return !string.IsNullOrWhiteSpace(connectionString);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
using HikvisionAttendanceManager.App.Models;
|
||||||
|
using MySql.Data.MySqlClient;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
/// <summary>HRMS reader aligned with UIND schema (employee.concatenated_name, is_active, department.title).</summary>
|
||||||
|
public sealed class HrmsEmployeeService
|
||||||
|
{
|
||||||
|
/// <summary>Display name expression used across all employee queries.</summary>
|
||||||
|
private const string EmployeeNameExpression =
|
||||||
|
"COALESCE(NULLIF(TRIM(e.concatenated_name), ''), TRIM(CONCAT(COALESCE(e.first_name,''), ' ', COALESCE(e.middle_name,''), ' ', COALESCE(e.last_name,''))))";
|
||||||
|
|
||||||
|
private const string EmployeeQuery = $"""
|
||||||
|
SELECT e.id,e.serial_number,{EmployeeNameExpression} AS employee_display_name,
|
||||||
|
e.department_id,e.is_active,e.location_site_id,e.has_photo,
|
||||||
|
d.title AS department_name,d.is_active AS department_is_active
|
||||||
|
FROM hrms.employee e
|
||||||
|
LEFT JOIN hrms.department d ON d.id = e.department_id
|
||||||
|
""";
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<LocationSite>> GetLocationSitesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
SELECT DISTINCT e.location_site_id,
|
||||||
|
COALESCE(ls.title, CONCAT('Site ', e.location_site_id)) AS site_title
|
||||||
|
FROM hrms.employee e
|
||||||
|
LEFT JOIN inventory.location_site ls ON ls.id = e.location_site_id
|
||||||
|
WHERE e.location_site_id IS NOT NULL
|
||||||
|
ORDER BY site_title
|
||||||
|
""";
|
||||||
|
return await QuerySitesAsync(sql, null, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<HrmsDepartment>> GetActiveDepartmentsBySiteAsync(long siteId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const string inventorySql = """
|
||||||
|
SELECT DISTINCT d.id, d.title, d.is_active
|
||||||
|
FROM hrms.department d
|
||||||
|
INNER JOIN inventory.department_location_site dls ON dls.department_id = d.id
|
||||||
|
WHERE d.is_active = 1 AND dls.site_id = @siteId
|
||||||
|
ORDER BY d.title
|
||||||
|
""";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var departments = await QueryDepartmentsAsync(inventorySql, cmd => cmd.Parameters.AddWithValue("@siteId", siteId), cancellationToken);
|
||||||
|
if (departments.Count > 0) return departments;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warning("Department load via inventory.department_location_site failed; falling back to employee-derived departments. " + ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const string fallbackSql = """
|
||||||
|
SELECT DISTINCT d.id, d.title, d.is_active
|
||||||
|
FROM hrms.department d
|
||||||
|
INNER JOIN hrms.employee e ON e.department_id = d.id
|
||||||
|
WHERE d.is_active = 1 AND e.is_active = 1 AND e.location_site_id = @siteId
|
||||||
|
ORDER BY d.title
|
||||||
|
""";
|
||||||
|
return await QueryDepartmentsAsync(fallbackSql, cmd => cmd.Parameters.AddWithValue("@siteId", siteId), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<HrmsDepartment>> GetActiveDepartmentsAsync(CancellationToken cancellationToken) =>
|
||||||
|
await QueryDepartmentsAsync("SELECT id, title, is_active FROM hrms.department WHERE is_active = 1 ORDER BY title", null, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<HrmsEmployee>> GetEmployeesForDepartmentsAsync(IEnumerable<long> departmentIds, long siteId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ids = departmentIds.Distinct().ToList();
|
||||||
|
if (ids.Count == 0) return [];
|
||||||
|
var placeholders = string.Join(",", ids.Select((_, i) => "@d" + i));
|
||||||
|
var sql = EmployeeQuery + $" WHERE e.department_id IN ({placeholders}) AND e.is_active = 1 AND d.is_active = 1 AND e.location_site_id = @siteId ORDER BY employee_display_name";
|
||||||
|
return await QueryEmployeesAsync(sql, command =>
|
||||||
|
{
|
||||||
|
for (var i = 0; i < ids.Count; i++)
|
||||||
|
command.Parameters.AddWithValue("@d" + i, ids[i]);
|
||||||
|
command.Parameters.AddWithValue("@siteId", siteId);
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<HrmsEmployee>> SearchEmployeesAsync(string query, long? siteId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(query)) return [];
|
||||||
|
var sql = EmployeeQuery + $" WHERE e.is_active = 1 AND (e.serial_number LIKE @q OR {EmployeeNameExpression} LIKE @q OR e.concatenated_name LIKE @q)";
|
||||||
|
if (siteId.HasValue) sql += " AND e.location_site_id = @siteId";
|
||||||
|
sql += " ORDER BY employee_display_name LIMIT 25";
|
||||||
|
return await QueryEmployeesAsync(sql, command =>
|
||||||
|
{
|
||||||
|
command.Parameters.AddWithValue("@q", "%" + query.Trim() + "%");
|
||||||
|
if (siteId.HasValue) command.Parameters.AddWithValue("@siteId", siteId.Value);
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HrmsEmployee?> FindBySerialNumberAsync(string serialNumber, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sql = EmployeeQuery + " WHERE e.serial_number = @serialNumber LIMIT 1";
|
||||||
|
return await QueryOneAsync(sql, command => command.Parameters.AddWithValue("@serialNumber", serialNumber), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<HrmsEmployee>> GetEmployeesForDepartmentAsync(long departmentId, long siteId, CancellationToken cancellationToken) =>
|
||||||
|
await QueryEmployeesAsync(EmployeeQuery + " WHERE e.department_id = @departmentId AND e.is_active = 1 AND d.is_active = 1 AND e.location_site_id = @siteId ORDER BY employee_display_name",
|
||||||
|
command => { command.Parameters.AddWithValue("@departmentId", departmentId); command.Parameters.AddWithValue("@siteId", siteId); }, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<HrmsEmployee>> GetEmployeesForAllActiveDepartmentsAsync(long siteId, CancellationToken cancellationToken) =>
|
||||||
|
await QueryEmployeesAsync(EmployeeQuery + " WHERE e.is_active = 1 AND d.is_active = 1 AND e.location_site_id = @siteId ORDER BY employee_display_name",
|
||||||
|
command => command.Parameters.AddWithValue("@siteId", siteId), cancellationToken);
|
||||||
|
|
||||||
|
private async Task<HrmsEmployee?> QueryOneAsync(string sql, Action<MySqlCommand>? parameters, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var list = await QueryEmployeesAsync(sql, parameters, cancellationToken);
|
||||||
|
return list.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<HrmsEmployee>> QueryEmployeesAsync(string sql, Action<MySqlCommand>? parameters, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var employees = new List<HrmsEmployee>();
|
||||||
|
await using var connection = HrmsConnectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
await using var command = new MySqlCommand(sql, connection);
|
||||||
|
parameters?.Invoke(command);
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
employees.Add(MapEmployee(reader));
|
||||||
|
return employees;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Error("HRMS employee query failed.", ex);
|
||||||
|
throw new HrmsDataException("Unable to load employees. Please check the HRMS connection.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IReadOnlyList<HrmsDepartment>> QueryDepartmentsAsync(string sql, Action<MySqlCommand>? parameters, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var departments = new List<HrmsDepartment>();
|
||||||
|
await using var connection = HrmsConnectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
await using var command = new MySqlCommand(sql, connection);
|
||||||
|
parameters?.Invoke(command);
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
departments.Add(new HrmsDepartment(
|
||||||
|
Convert.ToInt64(reader["id"]),
|
||||||
|
reader["title"]?.ToString() ?? "",
|
||||||
|
reader["is_active"] != DBNull.Value && Convert.ToInt32(reader["is_active"]) == 1));
|
||||||
|
}
|
||||||
|
return departments;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Error("HRMS department query failed.", ex);
|
||||||
|
throw new HrmsDataException("Unable to load departments. Please retry.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IReadOnlyList<LocationSite>> QuerySitesAsync(string sql, Action<MySqlCommand>? parameters, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sites = new List<LocationSite>();
|
||||||
|
await using var connection = HrmsConnectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
await using var command = new MySqlCommand(sql, connection);
|
||||||
|
parameters?.Invoke(command);
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
var id = Convert.ToInt64(reader["location_site_id"]);
|
||||||
|
var title = reader["site_title"]?.ToString();
|
||||||
|
sites.Add(new LocationSite(id, string.IsNullOrWhiteSpace(title) ? $"Site {id}" : title.Trim()));
|
||||||
|
}
|
||||||
|
return sites;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Error("HRMS location site query failed.", ex);
|
||||||
|
throw new HrmsDataException("Unable to load location sites. Please check the HRMS connection.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HrmsEmployee MapEmployee(System.Data.Common.DbDataReader reader) =>
|
||||||
|
new(
|
||||||
|
Convert.ToInt64(reader["id"]),
|
||||||
|
reader["serial_number"]?.ToString() ?? "",
|
||||||
|
reader["employee_display_name"]?.ToString()?.Trim() ?? "",
|
||||||
|
reader["department_id"] == DBNull.Value ? null : Convert.ToInt64(reader["department_id"]),
|
||||||
|
reader["is_active"] != DBNull.Value && Convert.ToInt32(reader["is_active"]) == 1,
|
||||||
|
reader["location_site_id"] == DBNull.Value ? null : Convert.ToInt64(reader["location_site_id"]),
|
||||||
|
reader["department_name"]?.ToString() ?? "",
|
||||||
|
reader["department_is_active"] != DBNull.Value && Convert.ToInt32(reader["department_is_active"]) == 1,
|
||||||
|
reader["has_photo"] != DBNull.Value && Convert.ToInt32(reader["has_photo"]) == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HrmsDataException : Exception
|
||||||
|
{
|
||||||
|
public HrmsDataException(string userMessage, Exception inner) : base(userMessage, inner) { }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using HikvisionAttendanceManager.App.Models;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
/// <summary>Persistent operator sync history stored locally (no credentials).</summary>
|
||||||
|
public sealed class OperationHistoryService
|
||||||
|
{
|
||||||
|
private static string HistoryPath =>
|
||||||
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"Utopia", "HikvisionAttendanceManager", "sync-history.json");
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<SyncHistoryEntry>> LoadAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!File.Exists(HistoryPath)) return [];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var stream = File.OpenRead(HistoryPath);
|
||||||
|
var entries = await JsonSerializer.DeserializeAsync<List<SyncHistoryEntry>>(stream, cancellationToken: cancellationToken);
|
||||||
|
return (entries ?? []).OrderByDescending(e => e.StartedAt).ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warning("Sync history load failed: " + ex.Message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddAsync(SyncHistoryEntry entry, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var all = (await LoadAsync(cancellationToken)).ToList();
|
||||||
|
all.Insert(0, entry);
|
||||||
|
await SaveAsync(all, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(SyncHistoryEntry entry, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var all = (await LoadAsync(cancellationToken)).ToList();
|
||||||
|
var index = all.FindIndex(e => e.Id == entry.Id);
|
||||||
|
if (index >= 0) all[index] = entry;
|
||||||
|
else all.Insert(0, entry);
|
||||||
|
await SaveAsync(all, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SaveAsync(List<SyncHistoryEntry> entries, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(HistoryPath)!;
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
await using var stream = File.Create(HistoryPath);
|
||||||
|
await JsonSerializer.SerializeAsync(stream, entries.OrderByDescending(e => e.StartedAt).Take(500).ToList(),
|
||||||
|
new JsonSerializerOptions { WriteIndented = true }, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
public static class PasswordProtector
|
||||||
|
{
|
||||||
|
public static string Protect(string password) =>
|
||||||
|
Convert.ToBase64String(ProtectedData.Protect(Encoding.UTF8.GetBytes(password), null, DataProtectionScope.CurrentUser));
|
||||||
|
|
||||||
|
public static string Unprotect(string? encryptedPassword) =>
|
||||||
|
string.IsNullOrWhiteSpace(encryptedPassword) ? "" :
|
||||||
|
Encoding.UTF8.GetString(ProtectedData.Unprotect(Convert.FromBase64String(encryptedPassword), null, DataProtectionScope.CurrentUser));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
using HikvisionAttendanceManager.App.Models;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
public sealed class TemplateSyncService(
|
||||||
|
HikvisionIsapiClient hikvision,
|
||||||
|
AttendanceMachineUserRepository machineUsers,
|
||||||
|
AttendanceMachineFaceTemplateRepository faceTemplates,
|
||||||
|
OperationHistoryService history)
|
||||||
|
{
|
||||||
|
public async Task<SyncHistoryEntry> DeviceToDbAsync(Device device, IProgress<UserSyncProgress>? progress, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entry = new SyncHistoryEntry
|
||||||
|
{
|
||||||
|
Operation = SyncOperationKind.DeviceToDb,
|
||||||
|
SourceDevice = device.DisplayName,
|
||||||
|
Status = "Running"
|
||||||
|
};
|
||||||
|
await history.AddAsync(entry, cancellationToken);
|
||||||
|
|
||||||
|
var users = await hikvision.GetUsersAsync(device, cancellationToken);
|
||||||
|
entry.Total = users.Count;
|
||||||
|
var success = 0;
|
||||||
|
var failed = 0;
|
||||||
|
var skipped = 0;
|
||||||
|
|
||||||
|
for (var index = 0; index < users.Count; index++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var user = users[index];
|
||||||
|
progress?.Report(new UserSyncProgress(index, users.Count, user.EmployeeNo, "Saving user to DB…", success, 0, 0, 0, skipped, failed));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await machineUsers.UpsertAsync(device.MachineId, user.EmployeeNo, user.Name, cancellationToken);
|
||||||
|
if (user.FaceCount > 0)
|
||||||
|
{
|
||||||
|
progress?.Report(new UserSyncProgress(index, users.Count, user.EmployeeNo, "Downloading face template…", success, 0, 0, 0, skipped, failed));
|
||||||
|
var face = await hikvision.DownloadFaceTemplateAsync(device, user.EmployeeNo, cancellationToken);
|
||||||
|
if (face.Success)
|
||||||
|
{
|
||||||
|
await faceTemplates.UpsertAsync(user.EmployeeNo, face.Bytes, DateTime.Now, true, device.MachineId, device.IpAddress, cancellationToken);
|
||||||
|
success++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
success++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
failed++;
|
||||||
|
AppLogger.Warning($"Device→DB failed for {user.EmployeeNo}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.CompletedAt = DateTime.Now;
|
||||||
|
entry.Success = success;
|
||||||
|
entry.Failed = failed;
|
||||||
|
entry.Skipped = skipped;
|
||||||
|
entry.Status = failed > 0 ? "Completed with errors" : "Completed";
|
||||||
|
await history.UpdateAsync(entry, cancellationToken);
|
||||||
|
progress?.Report(new UserSyncProgress(users.Count, users.Count, "", "Completed", success, 0, 0, 0, skipped, failed));
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SyncHistoryEntry> DbToDeviceAsync(Device source, Device target, IReadOnlyList<string> serialNumbers,
|
||||||
|
IProgress<UserSyncProgress>? progress, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.Equals(source.MachineId, target.MachineId, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
string.Equals(source.IpAddress, target.IpAddress, StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new InvalidOperationException("Source and target devices must be different.");
|
||||||
|
|
||||||
|
var entry = new SyncHistoryEntry
|
||||||
|
{
|
||||||
|
Operation = SyncOperationKind.DbToDevice,
|
||||||
|
SourceDevice = source.DisplayName,
|
||||||
|
TargetDevice = target.DisplayName,
|
||||||
|
Context = $"{serialNumbers.Count} user(s)",
|
||||||
|
Total = serialNumbers.Count,
|
||||||
|
Status = "Running"
|
||||||
|
};
|
||||||
|
await history.AddAsync(entry, cancellationToken);
|
||||||
|
|
||||||
|
var created = 0;
|
||||||
|
var faces = 0;
|
||||||
|
var skipped = 0;
|
||||||
|
var failed = 0;
|
||||||
|
|
||||||
|
for (var index = 0; index < serialNumbers.Count; index++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var serial = serialNumbers[index];
|
||||||
|
progress?.Report(new UserSyncProgress(index, serialNumbers.Count, serial, "Creating user on target…", created, 0, faces, 0, skipped, failed));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dbUsers = await machineUsers.GetActiveByMachineAsync(source.MachineId, cancellationToken);
|
||||||
|
var dbUser = dbUsers.FirstOrDefault(u => string.Equals(u.SerialNumber, serial, StringComparison.OrdinalIgnoreCase));
|
||||||
|
var name = dbUser?.EmployeeName ?? serial;
|
||||||
|
|
||||||
|
if (!await hikvision.UserExistsAsync(target, serial, cancellationToken))
|
||||||
|
{
|
||||||
|
var create = await hikvision.CreateUserAsync(target, serial, name, cancellationToken);
|
||||||
|
if (!create.Success)
|
||||||
|
{
|
||||||
|
failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var template = await faceTemplates.TryGetActiveAsync(serial, cancellationToken);
|
||||||
|
if (template?.Template is null or { Length: 0 })
|
||||||
|
{
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HikvisionIsapiClient.IsUploadableFaceTemplate(template.Template))
|
||||||
|
{
|
||||||
|
skipped++;
|
||||||
|
AppLogger.Info($"DB→Device skipped non-JPEG template for {serial}.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress?.Report(new UserSyncProgress(index, serialNumbers.Count, serial, "Uploading face template…", created, 0, faces, 0, skipped, failed));
|
||||||
|
var upload = await hikvision.UploadFaceAsync(target, serial, template.Template, cancellationToken);
|
||||||
|
if (upload.Success)
|
||||||
|
{
|
||||||
|
faces++;
|
||||||
|
await machineUsers.UpsertAsync(target.MachineId, serial, name, cancellationToken);
|
||||||
|
}
|
||||||
|
else failed++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
failed++;
|
||||||
|
AppLogger.Warning($"DB→Device failed for {serial}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.CompletedAt = DateTime.Now;
|
||||||
|
entry.Success = created;
|
||||||
|
entry.Failed = failed;
|
||||||
|
entry.Skipped = skipped;
|
||||||
|
entry.Context = $"{serialNumbers.Count} user(s), {faces} face(s) uploaded";
|
||||||
|
entry.Status = failed > 0 ? "Completed with errors" : "Completed";
|
||||||
|
await history.UpdateAsync(entry, cancellationToken);
|
||||||
|
progress?.Report(new UserSyncProgress(serialNumbers.Count, serialNumbers.Count, "", "Completed", created, 0, faces, 0, skipped, failed));
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
using HikvisionAttendanceManager.App.Models;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
public sealed class UserManagementService(
|
||||||
|
HikvisionIsapiClient hikvision,
|
||||||
|
EmployeePhotoService photos,
|
||||||
|
AttendanceMachineUserRepository machineUsers,
|
||||||
|
OperationHistoryService history)
|
||||||
|
{
|
||||||
|
public static EmployeeSyncResult ValidateEmployee(HrmsEmployee employee, long siteId)
|
||||||
|
{
|
||||||
|
var result = new EmployeeSyncResult
|
||||||
|
{
|
||||||
|
Employee = employee,
|
||||||
|
EmployeeNumber = employee.SerialNumber,
|
||||||
|
EmployeeName = employee.Name,
|
||||||
|
Department = employee.DepartmentName,
|
||||||
|
OverallResult = "READY"
|
||||||
|
};
|
||||||
|
if (!employee.Active) { result.OverallResult = "SKIPPED"; result.Reason = "Employee inactive."; }
|
||||||
|
else if (employee.DepartmentId is null) { result.OverallResult = "SKIPPED"; result.Reason = "Department not found."; }
|
||||||
|
else if (!employee.DepartmentActive) { result.OverallResult = "SKIPPED"; result.Reason = "Department inactive."; }
|
||||||
|
else if (employee.LocationSiteId != siteId) { result.OverallResult = "SKIPPED"; result.Reason = "Wrong location site."; }
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<EmployeeSyncResult> CreateUserWithFaceAsync(Device device, HrmsEmployee employee, long siteId,
|
||||||
|
IProgress<string>? progress, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var validation = ValidateEmployee(employee, siteId);
|
||||||
|
if (validation.OverallResult == "SKIPPED") return validation;
|
||||||
|
|
||||||
|
var entry = new SyncHistoryEntry
|
||||||
|
{
|
||||||
|
Operation = SyncOperationKind.UserCreation,
|
||||||
|
TargetDevice = device.DisplayName,
|
||||||
|
Context = employee.SerialNumber + " — " + employee.Name,
|
||||||
|
Total = 1,
|
||||||
|
Status = "Running"
|
||||||
|
};
|
||||||
|
await history.AddAsync(entry, cancellationToken);
|
||||||
|
|
||||||
|
var result = new EmployeeSyncResult
|
||||||
|
{
|
||||||
|
Employee = employee,
|
||||||
|
EmployeeNumber = employee.SerialNumber,
|
||||||
|
EmployeeName = employee.Name,
|
||||||
|
Department = employee.DepartmentName
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
progress?.Report("Checking if user already exists…");
|
||||||
|
if (await hikvision.UserExistsAsync(device, employee.SerialNumber, cancellationToken))
|
||||||
|
{
|
||||||
|
result.UserStatus = "Already exists";
|
||||||
|
result.OverallResult = "ALREADY_EXISTS";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
progress?.Report("Creating Hikvision UserInfo…");
|
||||||
|
var create = await hikvision.CreateUserAsync(device, employee, cancellationToken);
|
||||||
|
if (!create.Success)
|
||||||
|
{
|
||||||
|
result.UserStatus = "Failed";
|
||||||
|
result.OverallResult = "FAILED";
|
||||||
|
result.Reason = create.Reason;
|
||||||
|
await CompleteHistory(entry, result, cancellationToken);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result.UserStatus = "Created";
|
||||||
|
}
|
||||||
|
|
||||||
|
progress?.Report("Downloading employee photo…");
|
||||||
|
var photo = await photos.DownloadAndNormalizeAsync(employee, cancellationToken);
|
||||||
|
if (!photo.Success)
|
||||||
|
{
|
||||||
|
result.FaceStatus = "Failed";
|
||||||
|
result.OverallResult = "FAILED";
|
||||||
|
result.Reason = photo.Reason;
|
||||||
|
await CompleteHistory(entry, result, cancellationToken);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress?.Report("Uploading face…");
|
||||||
|
var face = await hikvision.UploadFaceAsync(device, employee.SerialNumber, photo.Bytes, cancellationToken);
|
||||||
|
if (!face.Success)
|
||||||
|
{
|
||||||
|
result.FaceStatus = "Failed";
|
||||||
|
result.OverallResult = "FAILED";
|
||||||
|
result.Reason = face.Reason;
|
||||||
|
await CompleteHistory(entry, result, cancellationToken);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result.FaceStatus = "Uploaded";
|
||||||
|
|
||||||
|
progress?.Report("Verifying face enrollment…");
|
||||||
|
var verify = await hikvision.VerifyFaceAsync(device, employee.SerialNumber, cancellationToken);
|
||||||
|
result.VerificationStatus = verify.Success ? "Verified" : "Failed";
|
||||||
|
result.OverallResult = verify.Success ? "SUCCESS" : "FAILED";
|
||||||
|
result.Reason = verify.Reason;
|
||||||
|
|
||||||
|
if (verify.Success)
|
||||||
|
await machineUsers.UpsertAsync(device.MachineId, employee.SerialNumber, employee.Name, cancellationToken);
|
||||||
|
|
||||||
|
await CompleteHistory(entry, result, cancellationToken);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
result.OverallResult = "FAILED";
|
||||||
|
result.Reason = ex is InvalidOperationException ? ex.Message : "Device unavailable or operation failed.";
|
||||||
|
await CompleteHistory(entry, result, cancellationToken);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<EmployeeSyncResult>> DeleteUsersAsync(Device device, IReadOnlyList<string> employeeNumbers,
|
||||||
|
IProgress<string>? progress, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var normalized = employeeNumbers.Where(e => !string.IsNullOrWhiteSpace(e)).Select(e => e.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
var entry = new SyncHistoryEntry
|
||||||
|
{
|
||||||
|
Operation = SyncOperationKind.UserDeletion,
|
||||||
|
TargetDevice = device.DisplayName,
|
||||||
|
Context = normalized.Count == 1 ? normalized[0] : $"{normalized.Count} employees",
|
||||||
|
Total = normalized.Count,
|
||||||
|
Status = "Running"
|
||||||
|
};
|
||||||
|
await history.AddAsync(entry, cancellationToken);
|
||||||
|
|
||||||
|
var results = new List<EmployeeSyncResult>();
|
||||||
|
if (normalized.Count == 0)
|
||||||
|
{
|
||||||
|
entry.Status = "Completed";
|
||||||
|
entry.CompletedAt = DateTime.Now;
|
||||||
|
await history.UpdateAsync(entry, cancellationToken);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress?.Report("Deleting users on device…");
|
||||||
|
var batchResult = await hikvision.DeleteUsersAsync(device, normalized, cancellationToken);
|
||||||
|
foreach (var employeeNo in normalized)
|
||||||
|
{
|
||||||
|
if (batchResult.Success)
|
||||||
|
{
|
||||||
|
await machineUsers.MarkDeletedAsync(device.MachineId, employeeNo, cancellationToken);
|
||||||
|
results.Add(new EmployeeSyncResult
|
||||||
|
{
|
||||||
|
EmployeeNumber = employeeNo,
|
||||||
|
UserStatus = "Deleted",
|
||||||
|
OverallResult = "SUCCESS"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
results.Add(new EmployeeSyncResult
|
||||||
|
{
|
||||||
|
EmployeeNumber = employeeNo,
|
||||||
|
UserStatus = "Failed",
|
||||||
|
OverallResult = "FAILED",
|
||||||
|
Reason = batchResult.Reason
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.CompletedAt = DateTime.Now;
|
||||||
|
entry.Success = results.Count(r => r.OverallResult == "SUCCESS");
|
||||||
|
entry.Failed = results.Count(r => r.OverallResult == "FAILED");
|
||||||
|
entry.Status = entry.Failed > 0 ? "Completed with errors" : "Completed";
|
||||||
|
await history.UpdateAsync(entry, cancellationToken);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CompleteHistory(SyncHistoryEntry entry, EmployeeSyncResult result, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
entry.CompletedAt = DateTime.Now;
|
||||||
|
entry.Success = result.OverallResult is "SUCCESS" or "ALREADY_EXISTS" ? 1 : 0;
|
||||||
|
entry.Failed = result.OverallResult == "FAILED" ? 1 : 0;
|
||||||
|
entry.Skipped = result.OverallResult == "SKIPPED" ? 1 : 0;
|
||||||
|
entry.Status = result.OverallResult == "FAILED" ? "Failed" : "Completed";
|
||||||
|
await history.UpdateAsync(entry, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
using HikvisionAttendanceManager.App.Models;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
public sealed class UserSyncEngine(HikvisionIsapiClient hikvision, EmployeePhotoService photos)
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<EmployeeSyncResult>> RunAsync(Device device, IEnumerable<HrmsEmployee> employees, bool initialEnrollment,
|
||||||
|
IProgress<UserSyncProgress>? progress, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var results = new List<EmployeeSyncResult>();
|
||||||
|
var source = employees.ToList();
|
||||||
|
for (var index = 0; index < source.Count; index++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var employee = source[index];
|
||||||
|
progress?.Report(CreateProgress(index, source.Count, employee.SerialNumber, "Creating user…", results));
|
||||||
|
var result = new EmployeeSyncResult { Employee = employee, EmployeeNumber = employee.SerialNumber, EmployeeName = employee.Name, Department = employee.DepartmentName };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (await hikvision.UserExistsAsync(device, employee.SerialNumber, cancellationToken))
|
||||||
|
result.UserStatus = "Already exists";
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var user = await hikvision.CreateUserAsync(device, employee, cancellationToken);
|
||||||
|
if (!user.Success) { result.UserStatus = "Failed"; result.OverallResult = "FAILED"; result.Reason = user.Reason; results.Add(result); continue; }
|
||||||
|
result.UserStatus = "Created";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!initialEnrollment) { result.OverallResult = result.UserStatus == "Already exists" ? "ALREADY_EXISTS" : "SUCCESS"; results.Add(result); continue; }
|
||||||
|
progress?.Report(CreateProgress(index, source.Count, employee.SerialNumber, "Downloading employee photo…", results));
|
||||||
|
var photo = await photos.DownloadAndNormalizeAsync(employee, cancellationToken);
|
||||||
|
if (!photo.Success) { result.FaceStatus = "Failed"; result.OverallResult = "FAILED"; result.Reason = photo.Reason; results.Add(result); continue; }
|
||||||
|
progress?.Report(CreateProgress(index, source.Count, employee.SerialNumber, "Uploading face…", results));
|
||||||
|
var face = await hikvision.UploadFaceAsync(device, employee.SerialNumber, photo.Bytes, cancellationToken);
|
||||||
|
if (!face.Success) { result.FaceStatus = "Failed"; result.OverallResult = "FAILED"; result.Reason = face.Reason; results.Add(result); continue; }
|
||||||
|
result.FaceStatus = "Uploaded";
|
||||||
|
progress?.Report(CreateProgress(index, source.Count, employee.SerialNumber, "Verifying face enrollment…", results));
|
||||||
|
var verification = await hikvision.VerifyFaceAsync(device, employee.SerialNumber, cancellationToken);
|
||||||
|
result.VerificationStatus = verification.Success ? "Verified" : "Failed";
|
||||||
|
result.OverallResult = verification.Success ? "SUCCESS" : "FAILED";
|
||||||
|
result.Reason = verification.Reason;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
result.OverallResult = "FAILED";
|
||||||
|
result.Reason = ex is InvalidOperationException ? ex.Message : "Device unavailable or operation failed.";
|
||||||
|
}
|
||||||
|
results.Add(result);
|
||||||
|
}
|
||||||
|
progress?.Report(CreateProgress(source.Count, source.Count, "", "Completed", results));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserSyncProgress CreateProgress(int completed, int total, string employee, string operation, IReadOnlyList<EmployeeSyncResult> results) =>
|
||||||
|
new(completed, total, employee, operation,
|
||||||
|
results.Count(r => r.UserStatus == "Created"), results.Count(r => r.UserStatus == "Already exists"),
|
||||||
|
results.Count(r => r.FaceStatus == "Uploaded"), results.Count(r => r.FaceStatus == "Failed"),
|
||||||
|
results.Count(r => r.OverallResult == "SKIPPED"), results.Count(r => r.OverallResult == "FAILED"));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
<UserControl x:Class="HikvisionAttendanceManager.App.SyncHistoryView"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<DockPanel Margin="0,0,0,16">
|
||||||
|
<StackPanel DockPanel.Dock="Left">
|
||||||
|
<TextBlock Text="Sync History" FontSize="25" FontWeight="SemiBold" Foreground="#17233B"/>
|
||||||
|
<TextBlock Text="Persistent history for attendance, user, template, and departmental sync operations." Margin="0,6,0,0" Foreground="#66758C"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Button DockPanel.Dock="Right" Content="Refresh" VerticalAlignment="Center" Click="Refresh_Click"/>
|
||||||
|
</DockPanel>
|
||||||
|
<Border Grid.Row="1" Style="{StaticResource CardStyle}" Padding="0">
|
||||||
|
<DataGrid x:Name="HistoryGrid" AutoGenerateColumns="False" IsReadOnly="True" CanUserAddRows="False" BorderThickness="0">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="Date/Time" Binding="{Binding StartedAtDisplay}" Width="160"/>
|
||||||
|
<DataGridTextColumn Header="Operation" Binding="{Binding OperationDisplay}" Width="150"/>
|
||||||
|
<DataGridTextColumn Header="Source" Binding="{Binding SourceDevice}" Width="*"/>
|
||||||
|
<DataGridTextColumn Header="Target" Binding="{Binding TargetDevice}" Width="*"/>
|
||||||
|
<DataGridTextColumn Header="Context" Binding="{Binding Context}" Width="*"/>
|
||||||
|
<DataGridTextColumn Header="Total" Binding="{Binding Total}" Width="60"/>
|
||||||
|
<DataGridTextColumn Header="Success" Binding="{Binding Success}" Width="70"/>
|
||||||
|
<DataGridTextColumn Header="Failed" Binding="{Binding Failed}" Width="60"/>
|
||||||
|
<DataGridTextColumn Header="Skipped" Binding="{Binding Skipped}" Width="70"/>
|
||||||
|
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="120"/>
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App;
|
||||||
|
|
||||||
|
public partial class SyncHistoryView : UserControl
|
||||||
|
{
|
||||||
|
private readonly OperationHistoryService _history = new();
|
||||||
|
private bool _initialized;
|
||||||
|
|
||||||
|
public SyncHistoryView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
if (_initialized) return;
|
||||||
|
_initialized = true;
|
||||||
|
await RefreshAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void Refresh_Click(object sender, RoutedEventArgs e) => await RefreshAsync();
|
||||||
|
|
||||||
|
private async Task RefreshAsync() => HistoryGrid.ItemsSource = await _history.LoadAsync();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
<UserControl x:Class="HikvisionAttendanceManager.App.UserManagementView"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="User Management" FontSize="25" FontWeight="SemiBold" Foreground="#17233B"/>
|
||||||
|
<TextBlock Text="Create individual Hikvision users with HRMS photo enrollment, or delete users by employee ID." Margin="0,6,0,22" Foreground="#66758C"/>
|
||||||
|
<Border Style="{StaticResource CardStyle}" Margin="0,0,0,14">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="HIKVISION DEVICE" Style="{StaticResource CardCaption}"/>
|
||||||
|
<DockPanel Margin="0,12,0,0">
|
||||||
|
<Button x:Name="TestConnectionButton" Content="Test Connection" DockPanel.Dock="Right" Style="{StaticResource PrimaryButton}" Click="TestConnection_Click"/>
|
||||||
|
<ComboBox x:Name="DeviceCombo" DisplayMemberPath="Name" MinWidth="310" HorizontalAlignment="Left"/>
|
||||||
|
</DockPanel>
|
||||||
|
<TextBlock x:Name="DeviceStatusText" Margin="0,10,0,0" Foreground="#66758C"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
<TabControl>
|
||||||
|
<TabItem Header="Create User">
|
||||||
|
<StackPanel Margin="0,12,0,0">
|
||||||
|
<Border Style="{StaticResource CardStyle}" Margin="0,0,0,14">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="HRMS EMPLOYEE" Style="{StaticResource CardCaption}"/>
|
||||||
|
<Grid Margin="0,12,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="220"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Column="0" Margin="0,0,10,0">
|
||||||
|
<TextBlock Text="Location Site" Foreground="#475569"/>
|
||||||
|
<ComboBox x:Name="SiteCombo" DisplayMemberPath="DisplayName" SelectionChanged="SiteChanged"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1" Margin="0,0,10,0">
|
||||||
|
<TextBlock Text="Search employee (serial or name)" Foreground="#475569"/>
|
||||||
|
<TextBox x:Name="SearchBox" KeyDown="SearchBox_KeyDown"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="2" Content="Search" VerticalAlignment="Bottom" Click="Search_Click"/>
|
||||||
|
</Grid>
|
||||||
|
<TextBlock x:Name="SearchStatusText" Margin="0,8,0,0" Foreground="#66758C"/>
|
||||||
|
<ListBox x:Name="SearchResults" DisplayMemberPath="Display" Height="120" Margin="0,8,0,0" SelectionChanged="SearchResults_Changed"/>
|
||||||
|
<TextBlock x:Name="SelectedEmployeeText" Margin="0,12,0,0" Foreground="#334155" TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
<Border Style="{StaticResource CardStyle}" Margin="0,0,0,14">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="CREATE & ENROLL" Style="{StaticResource CardCaption}"/>
|
||||||
|
<TextBlock x:Name="CreateProgressText" Margin="0,12,0,0" Foreground="#475569"/>
|
||||||
|
<Button Content="Create User" Style="{StaticResource PrimaryButton}" HorizontalAlignment="Left" Margin="0,14,0,0" Click="CreateUser_Click"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</TabItem>
|
||||||
|
<TabItem Header="Delete Users">
|
||||||
|
<StackPanel Margin="0,12,0,0">
|
||||||
|
<Border Style="{StaticResource CardStyle}" Margin="0,0,0,14">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="EMPLOYEE IDs TO DELETE" Style="{StaticResource CardCaption}"/>
|
||||||
|
<DockPanel Margin="0,12,0,0">
|
||||||
|
<Button Content="+ Add" DockPanel.Dock="Right" Click="AddDeleteId_Click"/>
|
||||||
|
<TextBox x:Name="DeleteIdBox" Margin="0,0,8,0" KeyDown="DeleteIdBox_KeyDown"/>
|
||||||
|
</DockPanel>
|
||||||
|
<WrapPanel x:Name="DeleteChips" Margin="0,12,0,0"/>
|
||||||
|
<Button Content="Delete Selected Users" Style="{StaticResource PrimaryButton}" HorizontalAlignment="Left" Margin="0,14,0,0" Click="DeleteUsers_Click"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</TabItem>
|
||||||
|
</TabControl>
|
||||||
|
<Border x:Name="ResultsCard" Style="{StaticResource CardStyle}" Visibility="Collapsed" Margin="0,14,0,0">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock x:Name="CompletionText" FontSize="17" FontWeight="SemiBold"/>
|
||||||
|
<DataGrid x:Name="ResultsGrid" AutoGenerateColumns="False" IsReadOnly="True" CanUserAddRows="False" Margin="0,12,0,0" MaxHeight="260">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="Employee" Binding="{Binding EmployeeNumber}" Width="100"/>
|
||||||
|
<DataGridTextColumn Header="Name" Binding="{Binding EmployeeName}" Width="*"/>
|
||||||
|
<DataGridTextColumn Header="User" Binding="{Binding UserStatus}" Width="100"/>
|
||||||
|
<DataGridTextColumn Header="Face" Binding="{Binding FaceStatus}" Width="100"/>
|
||||||
|
<DataGridTextColumn Header="Verification" Binding="{Binding VerificationStatus}" Width="100"/>
|
||||||
|
<DataGridTextColumn Header="Result" Binding="{Binding OverallResult}" Width="90"/>
|
||||||
|
<DataGridTextColumn Header="Reason" Binding="{Binding Reason}" Width="2*"/>
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</UserControl>
|
||||||
|
|
@ -0,0 +1,213 @@
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using HikvisionAttendanceManager.App.Models;
|
||||||
|
using HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
namespace HikvisionAttendanceManager.App;
|
||||||
|
|
||||||
|
public sealed class EmployeeSearchItem
|
||||||
|
{
|
||||||
|
public EmployeeSearchItem(HrmsEmployee employee) => Employee = employee;
|
||||||
|
public HrmsEmployee Employee { get; }
|
||||||
|
public string Display => $"{Employee.SerialNumber} — {Employee.Name} ({Employee.DepartmentName})";
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class UserManagementView : UserControl
|
||||||
|
{
|
||||||
|
private readonly DeviceService _devices = new();
|
||||||
|
private readonly DeviceConnectionService _connections = new();
|
||||||
|
private readonly HrmsEmployeeService _hrms = new();
|
||||||
|
private readonly UserManagementService _service;
|
||||||
|
private readonly ObservableCollection<string> _deleteIds = [];
|
||||||
|
private HrmsEmployee? _selectedEmployee;
|
||||||
|
private bool _initialized;
|
||||||
|
|
||||||
|
public UserManagementView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_service = new UserManagementService(new HikvisionIsapiClient(), new EmployeePhotoService(), new AttendanceMachineUserRepository(), new OperationHistoryService());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
if (_initialized) return;
|
||||||
|
_initialized = true;
|
||||||
|
DeviceStatusText.Text = "Loading devices…";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||||
|
DeviceCombo.ItemsSource = await Task.Run(async () => await _devices.LoadUnifiedAsync(timeout.Token), timeout.Token);
|
||||||
|
SiteCombo.ItemsSource = await _hrms.GetLocationSitesAsync(CancellationToken.None);
|
||||||
|
DeviceStatusText.Text = DeviceCombo.Items.Count == 0 ? "No Hikvision devices configured." : "";
|
||||||
|
}
|
||||||
|
catch (HrmsDataException ex)
|
||||||
|
{
|
||||||
|
DeviceStatusText.Text = ex.Message;
|
||||||
|
_initialized = false;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
DeviceStatusText.Text = "Unable to load HRMS data.";
|
||||||
|
_initialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void TestConnection_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DeviceCombo.SelectedItem is not Device device) { DeviceStatusText.Text = "Select a device first."; return; }
|
||||||
|
if (sender is not Button button) return;
|
||||||
|
|
||||||
|
var originalContent = button.Content;
|
||||||
|
button.IsEnabled = false;
|
||||||
|
button.Content = "Testing…";
|
||||||
|
DeviceStatusText.Text = "Testing connection…";
|
||||||
|
DeviceStatusText.Foreground = System.Windows.Media.Brushes.Gray;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _connections.TestAsync(device, CancellationToken.None).ConfigureAwait(true);
|
||||||
|
DeviceStatusText.Text = result.IsConnected ? "✓ " + result.Message.Replace('\n', ' ') : "✕ Connection failed — " + result.Message;
|
||||||
|
DeviceStatusText.Foreground = result.IsConnected ? System.Windows.Media.Brushes.ForestGreen : System.Windows.Media.Brushes.Firebrick;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Error("[DEVICE_TEST] Unexpected user-management test failure.", ex);
|
||||||
|
DeviceStatusText.Text = "✕ Connection failed — Unexpected error. See the application log.";
|
||||||
|
DeviceStatusText.Foreground = System.Windows.Media.Brushes.Firebrick;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
button.IsEnabled = true;
|
||||||
|
button.Content = originalContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SiteChanged(object sender, SelectionChangedEventArgs e) => _selectedEmployee = null;
|
||||||
|
|
||||||
|
private async void Search_Click(object sender, RoutedEventArgs e) => await SearchEmployeesAsync();
|
||||||
|
|
||||||
|
private async void SearchBox_KeyDown(object sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == Key.Enter) await SearchEmployeesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SearchEmployeesAsync()
|
||||||
|
{
|
||||||
|
var siteId = (SiteCombo.SelectedItem as LocationSite)?.Id;
|
||||||
|
var query = SearchBox.Text.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
SearchStatusText.Text = "Enter an employee serial number or name.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchStatusText.Text = "Searching…";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var results = await _hrms.SearchEmployeesAsync(query, siteId, CancellationToken.None);
|
||||||
|
SearchResults.ItemsSource = results.Select(e => new EmployeeSearchItem(e)).ToList();
|
||||||
|
SearchStatusText.Text = results.Count == 0
|
||||||
|
? "No employees found for that search."
|
||||||
|
: $"{results.Count} employee(s) found. Select one to review.";
|
||||||
|
SelectedEmployeeText.Text = results.Count == 0 ? "No employee selected." : "Select an employee from the list.";
|
||||||
|
_selectedEmployee = null;
|
||||||
|
}
|
||||||
|
catch (HrmsDataException ex)
|
||||||
|
{
|
||||||
|
SearchResults.ItemsSource = null;
|
||||||
|
SearchStatusText.Text = ex.Message;
|
||||||
|
SelectedEmployeeText.Text = "No employee selected.";
|
||||||
|
_selectedEmployee = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SearchResults_Changed(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
_selectedEmployee = (SearchResults.SelectedItem as EmployeeSearchItem)?.Employee;
|
||||||
|
if (_selectedEmployee is null)
|
||||||
|
{
|
||||||
|
SelectedEmployeeText.Text = "No employee selected.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var site = SiteCombo.SelectedItem as LocationSite;
|
||||||
|
var siteLabel = site?.DisplayName ?? (_selectedEmployee.LocationSiteId?.ToString() ?? "—");
|
||||||
|
var lines = new[]
|
||||||
|
{
|
||||||
|
$"Employee ID: {_selectedEmployee.SerialNumber}",
|
||||||
|
$"Employee Name: {_selectedEmployee.Name}",
|
||||||
|
$"Department: {(_selectedEmployee.DepartmentName ?? "—")}",
|
||||||
|
$"Location Site: {siteLabel}",
|
||||||
|
$"Employee Status: {(_selectedEmployee.Active ? "Active" : "Inactive")}",
|
||||||
|
$"Department Status: {(_selectedEmployee.DepartmentActive ? "Active" : "Inactive / missing")}",
|
||||||
|
$"Photo: {(_selectedEmployee.HasPhoto ? "Available in HRMS" : "Not available in HRMS")}"
|
||||||
|
};
|
||||||
|
SelectedEmployeeText.Text = string.Join(Environment.NewLine, lines);
|
||||||
|
|
||||||
|
if (site is not null)
|
||||||
|
{
|
||||||
|
var validation = UserManagementService.ValidateEmployee(_selectedEmployee, site.Id);
|
||||||
|
if (validation.OverallResult != "READY")
|
||||||
|
SelectedEmployeeText.Text += Environment.NewLine + Environment.NewLine + "Not eligible: " + validation.Reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void CreateUser_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DeviceCombo.SelectedItem is not Device device) { MessageBox.Show("Select a Hikvision device."); return; }
|
||||||
|
if (_selectedEmployee is null) { MessageBox.Show("Select an HRMS employee."); return; }
|
||||||
|
var siteId = (SiteCombo.SelectedItem as LocationSite)?.Id;
|
||||||
|
if (siteId is null) { MessageBox.Show("Select a location site."); return; }
|
||||||
|
|
||||||
|
ResultsCard.Visibility = Visibility.Visible;
|
||||||
|
CreateProgressText.Text = "Starting…";
|
||||||
|
var progress = new Progress<string>(msg => CreateProgressText.Text = msg);
|
||||||
|
var result = await _service.CreateUserWithFaceAsync(device, _selectedEmployee, siteId.Value, progress, CancellationToken.None);
|
||||||
|
ResultsGrid.ItemsSource = new[] { result };
|
||||||
|
CompletionText.Text = result.OverallResult is "SUCCESS" or "ALREADY_EXISTS"
|
||||||
|
? "User creation completed successfully."
|
||||||
|
: "User creation failed or was skipped.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddDeleteId_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var id = DeleteIdBox.Text.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(id) && !_deleteIds.Contains(id)) _deleteIds.Add(id);
|
||||||
|
DeleteIdBox.Clear();
|
||||||
|
RenderDeleteChips();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteIdBox_KeyDown(object sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == Key.Enter) AddDeleteId_Click(sender, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderDeleteChips()
|
||||||
|
{
|
||||||
|
DeleteChips.Children.Clear();
|
||||||
|
foreach (var id in _deleteIds.ToArray())
|
||||||
|
{
|
||||||
|
var button = new Button { Content = $"{id} ×", Margin = new Thickness(0, 0, 6, 6), Tag = id };
|
||||||
|
button.Click += (_, _) => { _deleteIds.Remove((string)button.Tag); RenderDeleteChips(); };
|
||||||
|
DeleteChips.Children.Add(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void DeleteUsers_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DeviceCombo.SelectedItem is not Device device) { MessageBox.Show("Select a Hikvision device."); return; }
|
||||||
|
if (_deleteIds.Count == 0) { MessageBox.Show("Add at least one employee ID."); return; }
|
||||||
|
if (MessageBox.Show($"Delete {_deleteIds.Count} user(s) from {device.Name}?", "Confirm deletion", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes)
|
||||||
|
return;
|
||||||
|
|
||||||
|
ResultsCard.Visibility = Visibility.Visible;
|
||||||
|
CreateProgressText.Text = "Deleting…";
|
||||||
|
var results = await _service.DeleteUsersAsync(device, _deleteIds.ToList(), new Progress<string>(msg => CreateProgressText.Text = msg), CancellationToken.None);
|
||||||
|
ResultsGrid.ItemsSource = results;
|
||||||
|
CompletionText.Text = $"Deletion completed — Success: {results.Count(r => r.OverallResult == "SUCCESS")}, Failed: {results.Count(r => r.OverallResult == "FAILED")}";
|
||||||
|
_deleteIds.Clear();
|
||||||
|
RenderDeleteChips();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"Hrms": "Server=YOUR_SERVER;Port=3306;Database=hrms;User ID=YOUR_USER;Password=YOUR_PASSWORD;"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{}
|
||||||
Loading…
Reference in New Issue