Initial commit

main
SYED MUSTUFA AHMED NAQVI 2026-08-24 12:35:29 +05:00
commit b4c15f6f7f
47 changed files with 4131 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@ -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.*

View File

@ -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

View File

@ -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);
}

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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 &amp; 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>

View File

@ -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;
}
}
}

View File

@ -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>

View File

@ -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);
}
}
}

View File

@ -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 &amp; 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 &amp; 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>

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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);
}
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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));
}

View File

@ -0,0 +1,3 @@
namespace HikvisionAttendanceManager.App.Models;
public sealed record HikvisionUser(string EmployeeNo, string Name, int FaceCount);

View File

@ -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);

View File

@ -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);

View File

@ -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.
}
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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 });
}
}

View File

@ -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, []);
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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) { }
}

View File

@ -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);
}
}

View File

@ -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));
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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"));
}

View File

@ -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>

View File

@ -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();
}

View File

@ -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 &amp; 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>

View File

@ -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();
}
}

View File

@ -0,0 +1,5 @@
{
"ConnectionStrings": {
"Hrms": "Server=YOUR_SERVER;Port=3306;Database=hrms;User ID=YOUR_USER;Password=YOUR_PASSWORD;"
}
}

View File

@ -0,0 +1 @@
{}