add hour window

This commit is contained in:
2025-12-16 21:47:49 +01:00
parent bd31fb6eb0
commit dc22000c79
10 changed files with 638 additions and 62 deletions

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows10.0.17763.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>

133
Blueberry/HoursWindow.xaml Normal file
View File

@@ -0,0 +1,133 @@
<ui:FluentWindow x:Class="Blueberry.HoursWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:local="clr-namespace:Blueberry"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance local:HoursWindow}"
Loaded="FluentWindow_Loaded"
Title="HoursWindow" Height="550" Width="800" MinWidth="670" MinHeight="450">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="1*" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ui:TitleBar Title="Órák" Grid.ColumnSpan="2">
<ui:TitleBar.Icon>
<ui:ImageIcon Source="/bb.ico" />
</ui:TitleBar.Icon>
</ui:TitleBar>
<Grid x:Name="userSelectionGrid" Grid.Row="1" Grid.ColumnSpan="2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ui:TextBlock Text="Felhasználó" VerticalAlignment="Center" Margin="10" />
<ComboBox x:Name="userComboBox" Grid.Column="1" Margin="10, 10, 5, 10" IsEditable="True" DisplayMemberPath="FullName" />
<ui:Button Grid.Column="2" Content="Dátum" Margin="5, 10" Padding="10" x:Name="dateButton" Click="dateButton_Click">
<ui:Button.Icon>
<ui:SymbolIcon Symbol="Timeline24" />
</ui:Button.Icon>
</ui:Button>
<ui:Button Grid.Column="3" Content="Keresés" Margin="5, 10, 10, 10" Padding="10" x:Name="searchButton" Click="searchButton_Click">
<ui:Button.Icon>
<ui:SymbolIcon Symbol="Search24" />
</ui:Button.Icon>
</ui:Button>
<ui:Flyout Grid.Column="2" x:Name="calendarFlyout">
<StackPanel Orientation="Vertical">
<Calendar x:Name="userCalendar" SelectionMode="SingleRange" IsTodayHighlighted="True" />
</StackPanel>
</ui:Flyout>
</Grid>
<ui:ProgressRing x:Name="hoursProgress" Grid.Row="2" Height="50" Width="50" IsIndeterminate="True" />
<ui:Card Grid.Row="2" Margin="10, 10, 0, 10" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
HorizontalContentAlignment="Stretch" VerticalContentAlignment="Stretch">
<ui:ListView ItemsSource="{Binding Hours}" Grid.IsSharedSizeScope="True" ScrollViewer.HorizontalScrollBarVisibility="Disabled"
VirtualizingPanel.ScrollUnit="Pixel">
<ui:ListView.ItemTemplate>
<DataTemplate>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" SharedSizeGroup="x" />
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="Auto" SharedSizeGroup="y" />
</Grid.ColumnDefinitions>
<ui:Card Visibility="{Binding CardVisibility}" Padding="50, 2, 50, 2" Margin="50, 2, 50, 2" Grid.ColumnSpan="4" HorizontalAlignment="Center">
<ui:TextBlock Text="{Binding SeparatorText}" FontStyle="Italic" FontWeight="Bold" Margin="0, 6, 0, 0"
FontSize="10" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</ui:Card>
<ui:Button Grid.Column="0" Margin="5" Content="{Binding IssueId}" x:Name="openIssueButton" Click="openIssueButton_Click" Visibility="{Binding ButtonVisibility}"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="2*" />
<RowDefinition Height="1*" />
</Grid.RowDefinitions>
<ui:TextBlock Grid.Row="0" Margin="5, 2, 5, 0" Text="{Binding IssueName}" TextTrimming="CharacterEllipsis" />
<ui:TextBlock Grid.Row="1" Margin="5, 0, 5, 2" Text="{Binding ProjectName}" FontSize="10" TextTrimming="CharacterEllipsis" />
</Grid>
<ui:TextBlock Grid.Column="2" Margin="5" Text="{Binding Comments}" ToolTip="{Binding Comments}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<ui:TextBlock Grid.Column="3" Margin="5, 5, 10, 5" Text="{Binding Hours}" FontSize="18" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ui:ListView.ItemTemplate>
</ui:ListView>
</ui:Card>
<ui:Card Grid.Column="1" Grid.Row="2" VerticalAlignment="Stretch" HorizontalAlignment="Stretch" Margin="10">
<Grid x:Name="dataGrid" VerticalAlignment="Stretch">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="1*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="1*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ui:TextBlock Grid.Row="3" Margin="0,150" />
<ui:TextBlock Grid.Column="0" Grid.Row="0" Margin="5" Text="Összes óra:" />
<ui:TextBlock Grid.Column="0" Grid.Row="1" Margin="5" Text="Átlag (per nap):" />
<ui:TextBlock Grid.Column="0" Grid.Row="2" Margin="5" Text="Átlag (per munkanap):" />
<ui:TextBlock Grid.Column="0" Grid.Row="4" Margin="5" Text="Új jegyek:" />
<ui:TextBlock Grid.Column="0" Grid.Row="5" Margin="5" Text="Lezárt jegyek:" />
<ui:TextBlock Grid.Column="0" Grid.Row="6" Margin="5" Text="Min. jegy életkor:" />
<ui:TextBlock Grid.Column="0" Grid.Row="7" Margin="5" Text="Max. jegy életkor:" />
<ui:TextBlock Grid.Column="0" Grid.Row="8" Margin="5" Text="Átlag jegy életkor:" />
<ui:TextBlock Grid.Column="0" Grid.Row="9" Margin="5" Text="Medián jegy életkor:" />
<ui:TextBlock Grid.Column="1" Grid.Row="0" Margin="5" Text="0.0" x:Name="totalHoursTextBlock" />
<ui:TextBlock Grid.Column="1" Grid.Row="1" Margin="5" Text="0.0" x:Name="avgDayTextBlock" />
<ui:TextBlock Grid.Column="1" Grid.Row="2" Margin="5" Text="0.0" x:Name="avgWorkdayTextBlock"/>
<ui:TextBlock Grid.Column="1" Grid.Row="4" Margin="5" Text="0.0" x:Name="newTicketsTextBlock" />
<ui:TextBlock Grid.Column="1" Grid.Row="5" Margin="5" Text="0.0" x:Name="closedTicketsTextBlock" />
<ui:TextBlock Grid.Column="1" Grid.Row="6" Margin="5" Text="0.0" x:Name="minTicketAgeTextBlock" />
<ui:TextBlock Grid.Column="1" Grid.Row="7" Margin="5" Text="0.0" x:Name="maxTicketAgeTextBlock" />
<ui:TextBlock Grid.Column="1" Grid.Row="8" Margin="5" Text="0.0" x:Name="avgTicketAgeTextBlock" />
<ui:TextBlock Grid.Column="1" Grid.Row="9" Margin="5" Text="0.0" x:Name="medianTicketAgeTextBlock" />
</Grid>
</ui:Card>
</Grid>
</ui:FluentWindow>

View File

@@ -0,0 +1,193 @@
using Blueberry.Redmine;
using Blueberry.Redmine.Dto;
using System.Collections.Concurrent;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using System.Windows;
using Wpf.Ui.Controls;
namespace Blueberry
{
/// <summary>
/// Interaction logic for HoursWindow.xaml
/// </summary>
public partial class HoursWindow : FluentWindow
{
private readonly List<UserInfo.User> _users = [];
private readonly RedmineManager _manager;
private readonly RedmineConfig _config;
private readonly ConcurrentDictionary<int, string> _issueNames = [];
public ObservableCollection<DisplayHours> Hours { get; set; } = [];
public HoursWindow(RedmineManager manager, RedmineConfig config)
{
InitializeComponent();
DataContext = this;
_manager = manager;
_config = config;
}
private async void FluentWindow_Loaded(object sender, System.Windows.RoutedEventArgs e)
{
var u = await _manager.GetUsersAsync();
var current = await _manager.GetCurrentUserAsync();
hoursProgress.Visibility = Visibility.Hidden;
_users.Clear();
_users.AddRange(u);
userComboBox.Items.Clear();
foreach (var user in u)
userComboBox.Items.Add(user);
userComboBox.SelectedItem = current;
}
private void dateButton_Click(object sender, System.Windows.RoutedEventArgs e)
{
calendarFlyout.IsOpen = true;
}
private async void searchButton_Click(object sender, System.Windows.RoutedEventArgs e)
{
var user = userComboBox.SelectedItem as UserInfo.User;
if(user is null)
return;
var selectedDates = userCalendar.SelectedDates;
if (selectedDates.Count == 0)
return;
ConcurrentBag<DisplayHours> hours = [];
Task[] tasks = new Task[selectedDates.Count];
Hours.Clear();
hoursProgress.Visibility= Visibility.Visible;
for (int i = 0; i < selectedDates.Count; i++)
{
var date = selectedDates[i];
tasks[i] = Task.Run(async () =>
{
var h = await _manager.GetTimeForUserAsync(user.Id, date, date);
foreach (var item in h)
{
var dh = new DisplayHours()
{
ProjectName = item.Project.Name,
IssueName = await GetIssueNameAsync(item.Issue.Id),
IssueId = item.Issue.Id.ToString(),
Date = date,
CreatedOn = item.CreatedOn,
Hours = item.Hours.ToString(),
Comments = item.Comments,
IsSeparator = false
};
hours.Add(dh);
}
});
await Task.Delay(10);
}
await Task.WhenAll(tasks);
var newTickets = await _manager.GetIssuesAsync(user.Id, createdFrom: selectedDates.First(), createdTo: selectedDates.Last());
var closedTickets = await _manager.GetIssuesAsync(user.Id, isOpen: false, updatedFrom: selectedDates.First(), updatedTo: selectedDates.Last());
var currentTickets = await _manager.GetUserOpenIssuesAsync(user.Id);
var total = hours.Sum(x => double.Parse(x.Hours));
totalHoursTextBlock.Text = total.ToString();
avgDayTextBlock.Text = Math.Round(total / selectedDates.Count, 2).ToString();
int workingDays = 0;
foreach (var day in selectedDates)
{
if(day.DayOfWeek != DayOfWeek.Saturday &&
day.DayOfWeek != DayOfWeek.Sunday)
workingDays++;
}
avgWorkdayTextBlock.Text = Math.Round(total / workingDays, 2).ToString();
newTicketsTextBlock.Text = newTickets.Count.ToString();
closedTicketsTextBlock.Text = closedTickets.Count.ToString();
minTicketAgeTextBlock.Text = Math.Round(currentTickets.Min(x => (DateTime.Now - x.CreatedOn).TotalDays), 2) + " nap";
maxTicketAgeTextBlock.Text = Math.Round(currentTickets.Max(x => (DateTime.Now - x.CreatedOn).TotalDays), 2) + " nap";
avgTicketAgeTextBlock.Text = Math.Round(currentTickets.Average(x => (DateTime.Now - x.CreatedOn).TotalDays), 2) + " nap";
var ages = currentTickets.Select(x => (DateTime.Now - x.CreatedOn).TotalDays).Order().ToList();
medianTicketAgeTextBlock.Text = Math.Round(ages[ages.Count / 2], 2) + " nap";
var orderedHours = hours.OrderByDescending(h => h.CreatedOn).OrderBy(h => h.Date).ToList();
var previousDate = DateTime.MinValue;
for (int i = 0; i < orderedHours.Count; i++)
{
if(orderedHours[i].Date.Date > previousDate)
{
total = orderedHours.Where(x => x.Date.Date == orderedHours[i].Date.Date).Sum(x => double.Parse(x.Hours));
var dh = new DisplayHours()
{
IsSeparator = true,
SeparatorText = orderedHours[i].Date.ToString("yyyy-MM-dd") + " | Összesen: " + total + " óra"
};
orderedHours.Insert(i, dh);
previousDate = orderedHours[i+1].Date.Date;
}
}
hoursProgress.Visibility = Visibility.Hidden;
foreach (var item in orderedHours)
Hours.Add(item);
}
private async Task<string> GetIssueNameAsync(int issueId)
{
try
{
if (_issueNames.ContainsKey(issueId))
return _issueNames[issueId];
var name = (await _manager.GetSimpleIssueAsync(issueId)).Subject;
_issueNames.TryAdd(issueId, name);
return name;
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
Console.WriteLine(issueId);
throw;
}
}
public class DisplayHours
{
public string ProjectName { get; set; } = "";
public string IssueName { get; set; } = "";
public string IssueId { get; set; } = "";
public DateTime Date { get; set; }
public DateTime CreatedOn { get; set; }
public string Hours { get; set; } = "";
public string Comments { get; set; } = "";
public string SeparatorText { get; set; } = "";
public bool IsSeparator { get; set; }
public Visibility CardVisibility => IsSeparator ? Visibility.Visible : Visibility.Hidden;
public Visibility ButtonVisibility => IsSeparator ? Visibility.Hidden : Visibility.Visible;
}
private async void openIssueButton_Click(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement button && button.DataContext is DisplayHours item)
{
var issueId = int.Parse(item.IssueId);
try
{
var issue = await _manager.GetIssueAsync(issueId);
var issueWindow = new IssueWindow(issue, _manager, _config);
issueWindow.Show();
}
catch (Exception)
{
}
}
}
}
}

View File

@@ -8,6 +8,7 @@
mc:Ignorable="d"
d:DataContext="{d:DesignInstance local:IssueWindow}"
Loaded="FluentWindow_Loaded"
Closing="FluentWindow_Closing"
Title="IssueWindow" Height="500" Width="900">
<Grid>
<Grid.RowDefinitions>

View File

@@ -4,6 +4,7 @@ using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Windows;
using System.Windows.Media;
using Windows.Networking.NetworkOperators;
using Wpf.Ui.Controls;
namespace Blueberry
@@ -17,6 +18,7 @@ namespace Blueberry
private readonly RedmineManager _manager;
private readonly RedmineConfig _config;
private readonly List<JournalDisplay> _journalDisplays = [];
private CancellationTokenSource _tokenSource = new();
public ObservableCollection<JournalDisplay> Journals { get; set; } = [];
public IssueWindow(DetailedIssue.Issue issue, RedmineManager manager, RedmineConfig config)
@@ -37,7 +39,12 @@ namespace Blueberry
iUpdatedTextBox.Text = _issue.UpdatedOn.ToString("yyyy-MM-dd");
iSpentTimeTextBox.Text = _issue.SpentHours.ToString();
journalProgressRing.Visibility = Visibility.Visible;
var hours = await _manager.GetTimeOnIssue(_issue.Id);
List<TimeOnIssue.TimeEntry> hours = [];
try
{
hours = await _manager.GetTimeOnIssue(_issue.Id, progress: UpdateProgress(), token: _tokenSource.Token);
} catch { }
_journalDisplays.AddRange(await ProcessJournal(_issue.Journals, hours));
if(!_journalDisplays.Any(x=>!x.IsData))
detailsToggleSwitch.IsChecked = true;
@@ -45,6 +52,20 @@ namespace Blueberry
journalProgressRing.Visibility = Visibility.Hidden;
}
private IProgress<(int, int)> UpdateProgress()
{
var p = new Progress<(int current, int total)>((x) =>
{
Dispatcher.Invoke(() =>
{
journalProgressRing.IsIndeterminate = false;
var percent = (int)Math.Round((double)x.current / x.total * 100);
journalProgressRing.Progress = percent;
});
});
return p;
}
private async Task LoadJournal()
{
var showDetails = detailsToggleSwitch.IsChecked ?? true;
@@ -72,6 +93,11 @@ namespace Blueberry
};
Process.Start(psi);
}
private void FluentWindow_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
_tokenSource.Cancel();
}
}
public partial class IssueWindow

View File

@@ -368,10 +368,8 @@ namespace BlueMine
private async void hoursButton_Click(object sender, RoutedEventArgs e)
{
await new Wpf.Ui.Controls.MessageBox
{
Title = "Under construction"
}.ShowDialogAsync();
var hoursWindow = new HoursWindow(_manager, _config);
hoursWindow.Show();
}
private void updateButton_Click(object sender, RoutedEventArgs e)
@@ -487,7 +485,7 @@ namespace BlueMine
foreach (var issueId in Constants.StaticTickets)
_issues.Add(await _manager.GetSimpleIssueAsync(issueId));
_issues.AddRange(await _manager.GetCurrentUserIssuesAsync(progress: UpdateProgress("Jegyek letöltése:")));
_issues.AddRange(await _manager.GetCurrentUserOpenIssuesAsync(progress: UpdateProgress("Jegyek letöltése:")));
progressBar.Value = 0;
progressRing.Visibility = Visibility.Hidden;
FilterIssues();
@@ -528,7 +526,7 @@ namespace BlueMine
statusTextBlock.Text = $"Kapcsolódás Redminehoz...";
int maxRetries = 3;
int timeoutSeconds = 1; // Force kill after 5s
int timeoutSeconds = 3; // Force kill after 5s
for (int i = 0; i < maxRetries; i++)
{
@@ -555,7 +553,7 @@ namespace BlueMine
statusTextBlock.Text = $"Kapcsolódási hiba. Újrapróbálkozás: {i + 1}/{maxRetries}";
// Wait 1 second before retrying
await Task.Delay(1000);
await Task.Delay(5000);
}
// All attempts failed