Browse Source

Added Bills to invoices and improved filtering on invoice screen

Kenric Nugteren 2 days ago
parent
commit
8a1f289c04

+ 2 - 1
prs.classes/Entities/Assignment/Assignment.cs

@@ -12,7 +12,8 @@ namespace Comal.Classes
     [UserTracking("Assignments")]
     [Caption("Assignments")]
     public class Assignment : Entity, IPersistent, IRemotable, INumericAutoIncrement<Assignment>, IOneToMany<Employee>, IOneToMany<Job>,
-        IOneToMany<Kanban>, ILicense<SchedulingControlLicense>, IJobActivity, IOneToMany<Invoice>, IJobScopedItem
+        IOneToMany<Kanban>, ILicense<SchedulingControlLicense>, IJobActivity, IOneToMany<Invoice>, IJobScopedItem,
+        IInvoiceable
     {
         [IntegerEditor(Editable = Editable.Hidden)]
         [EditorSequence(1)]

+ 10 - 1
prs.classes/Entities/Bill/BillLine.cs

@@ -32,7 +32,9 @@ namespace Comal.Classes
     }
 
     [UserTracking(typeof(Bill))]
-    public class BillLine : Entity, IPersistent, IRemotable, IOneToMany<Bill>, ITaxable, ILicense<AccountsPayableLicense>, IPostableFragment<Bill>, IEntityLookup<BillLine, BillLineLookups>
+    public class BillLine : Entity, IPersistent, IRemotable,
+        IOneToMany<Bill>, ITaxable, ILicense<AccountsPayableLicense>, IPostableFragment<Bill>, IEntityLookup<BillLine, BillLineLookups>,
+        IInvoiceable
     {
         [RequiredColumn]
         [EntityRelationship(DeleteAction.Cascade)]
@@ -134,6 +136,13 @@ namespace Comal.Classes
         [EditorSequence(12)]
         public CostCentreLink CostCentre { get; set; }
         
+        [EditorSequence(13)]
+        [Editable(Editable.Disabled)]
+        public InvoiceLink Invoice { get; set; }
+
+        [EditorSequence(14)]
+        public ActualCharge Charge { get; set; }
+        
         [NullEditor]
         public double TaxRate { get; set; }
         

+ 4 - 0
prs.classes/Entities/Customer/Customer.cs

@@ -120,6 +120,10 @@ namespace Comal.Classes
         [Formula(typeof(CustomerBalance))]
         public double Balance { get; set; }
 
+        [EditorSequence("Accounts", 8)]
+        [CurrencyEditor]
+        public double Markup { get; set; }
+
         [NullEditor]
         [ComplexFormula(typeof(ActiveSchedulesFormula<Customer>))]
         public int ActiveSchedules { get; set; }

+ 2 - 0
prs.classes/Entities/Customer/CustomerLink.cs

@@ -56,5 +56,7 @@ namespace Comal.Classes
         [NullEditor]
         [RequiredColumn]
         public SalesGLCodeLink GLCode { get; set; }
+
+        public double Markup { get; set; }
     }
 }

+ 13 - 0
prs.classes/Entities/Invoice/IInvoiceable.cs

@@ -0,0 +1,13 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace Comal.Classes
+{
+    public interface IInvoiceable
+    {
+        InvoiceLink Invoice { get; set; }
+
+        ActualCharge Charge { get; set; }
+    }
+}

+ 1 - 1
prs.classes/Entities/Stock/StockMovement/StockMovement.cs

@@ -15,7 +15,7 @@ namespace Comal.Classes
 
     [UserTracking("Warehousing")]
     public class StockMovement : StockEntity, IRemotable, IPersistent, IOneToMany<StockLocation>, IOneToMany<Product>, 
-        ILicense<WarehouseLicense>, IStockHolding, IJobMaterial, IExportable, IImportable, IPostable
+        ILicense<WarehouseLicense>, IStockHolding, IJobMaterial, IExportable, IImportable, IPostable, IInvoiceable
     {
 
         [DateTimeEditor]

+ 3 - 130
prs.desktop/Panels/Invoices/InvoiceAssignmentGrid.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Linq;
+using System.Linq.Expressions;
 using System.Threading;
 using System.Windows;
 using System.Windows.Controls;
@@ -12,31 +13,13 @@ using InABox.WPF;
 
 namespace PRSDesktop
 {
-    public class InvoiceAssignmentGrid : DynamicDataGrid<Assignment>
+    public class InvoiceAssignmentGrid : InvoiceableGrid<Assignment>
     {
-        private readonly BitmapImage chargeable_excluded = PRSDesktop.Resources.tick.Fade(0.8F).AsGrayScale().AsBitmapImage();
-        private readonly BitmapImage chargeable_included = PRSDesktop.Resources.tick.AsBitmapImage();
-        
-        private readonly BitmapImage unchargeable_excluded = PRSDesktop.Resources.warning.Fade(0.8F).AsGrayScale().AsBitmapImage();
-        private readonly BitmapImage unchargeable_included = PRSDesktop.Resources.warning.AsBitmapImage();
-        
-        private readonly Button IncludeButton;
-        
-        private readonly Button ShowAllButton;
-        private bool _showall = false;
+        protected override Expression<Func<Assignment, JobLink>> JobColumn => x => x.JobLink;
 
         public InvoiceAssignmentGrid()
         {
-            
             ColumnsTag = "InvoiceTimeSheets";
-
-            HiddenColumns.Add(x => x.Invoice.ID);
-            HiddenColumns.Add(x => x.Charge.Chargeable);
-
-            ActionColumns.Add(new DynamicImageColumn(IncludeImage, IncludeOne));
-            AddEditButton("Exclude", chargeable_excluded, IncludeSelected, DynamicGridButtonPosition.Right);
-            IncludeButton = AddEditButton("Include", chargeable_included, IncludeSelected, DynamicGridButtonPosition.Right);
-            ShowAllButton = AddButton("Show All", null, ToggleShowAll);
         }
 
         protected override void DoReconfigure(DynamicGridOptions options)
@@ -49,115 +32,5 @@ namespace PRSDesktop
             options.MultiSelect = true;
             options.FilterRows = true;
         }
-        
-        public Invoice Invoice { get; set; }
-
-        private bool IncludeSelected(Button sender, CoreRow[] rows)
-        {
-            if (Invoice.ID != Guid.Empty)
-            {
-                using (new WaitCursor())
-                {
-                    var bAdd = sender == IncludeButton;
-                    var assignments = rows.Select(r => r.ToObject<Assignment>()).ToArray();
-                    foreach (var assignment in assignments)
-                    {
-                        if (bAdd)
-                        {
-                            assignment.Invoice.ID = Invoice.ID;
-                            assignment.Invoice.Synchronise(Invoice);
-                        }
-                        else
-                        {
-                            assignment.Invoice.ID = Guid.Empty;
-                            assignment.Invoice.Synchronise(new Invoice());
-                        }
-                    }
-                    new Client<Assignment>().Save(assignments, bAdd ? "Added to Invoice" : "Removed From Invoice", (o, e) => { });
-                    foreach (var row in rows)
-                    {
-                        row.Set<Assignment, Guid>(x => x.Invoice.ID, bAdd ? Invoice.ID : Guid.Empty);
-                        InvalidateRow(row);
-                    }
-                }
-            }
-            else
-                MessageBox.Show("Please Select or Create an Invoice First!");
-            return false;
-        }
-
-        private bool IncludeOne(CoreRow arg)
-        {
-            if (arg == null)
-                return false;
-            
-            if (Invoice.ID != Guid.Empty)
-            {
-                var id = arg.Get<Assignment, Guid>(x => x.ID);
-                var assignment = arg.ToObject<Assignment>();
-                if (assignment != null)
-                {
-                    if (assignment.Invoice.IsValid())
-                    {
-                        assignment.Invoice.ID = Invoice.ID;
-                        assignment.Invoice.Synchronise(Invoice);
-                    }
-                    else
-                    {
-                        assignment.Invoice.ID = Guid.Empty;
-                        assignment.Invoice.Synchronise(new Invoice());
-                    }
-                    new Client<Assignment>().Save(assignment, "Added to Invoice", (o,e) => { });
-                    arg.Set<Assignment, Guid>(x=>x.Invoice.ID, assignment.Invoice.ID);
-                    InvalidateRow(arg);
-                }
-            }
-            else
-                MessageBox.Show("Please Select or Create an Invoice First!");
-            return false;
-        }
-        
-        private bool ToggleShowAll(Button arg1, CoreRow[] rows)
-        {
-            _showall = !_showall;
-            UpdateButton(ShowAllButton, null, _showall ? "Selected Only" : "Show All");
-            return true;
-        }
-
-        private BitmapImage IncludeImage(CoreRow arg)
-        {
-            if (arg == null)
-                return chargeable_included;
-            bool chargeable = arg.Get<Assignment, bool>(x => x.Charge.Chargeable);
-            bool included = Entity.IsEntityLinkValid<Assignment, InvoiceLink>(x => x.Invoice, arg);
-            return chargeable
-                ? included 
-                    ? chargeable_included 
-                    : chargeable_excluded
-                : included
-                    ? unchargeable_included
-                    : unchargeable_excluded;
-        }
-
-        protected override void Reload(
-        	Filters<Assignment> criteria, Columns<Assignment> columns, ref SortOrder<Assignment>? sort,
-        	CancellationToken token, Action<CoreTable?, Exception?> action)
-        {
-            
-            if (Invoice.ID == Guid.Empty)
-                criteria.Add(new Filter<Assignment>().None());
-            else
-            {
-                
-                criteria.Add(Filter<Assignment>.Where(x => x.JobLink.ID).IsEqualTo(Invoice.JobLink.ID));
-                
-                if (_showall)
-                    criteria.Add(Filter<Assignment>.Where(x => x.Invoice.ID).IsEqualTo(Invoice.ID).Or(x => x.Invoice).NotLinkValid());
-                else
-                    criteria.Add(Filter<Assignment>.Where(x => x.Invoice.ID).IsEqualTo(Invoice.ID)); 
-                
-            }
-            base.Reload(criteria, columns, ref sort, token, action);
-        }
     }
 }

+ 53 - 0
prs.desktop/Panels/Invoices/InvoiceBillLineGrid.cs

@@ -0,0 +1,53 @@
+using System;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Threading;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media.Imaging;
+using Comal.Classes;
+using InABox.Clients;
+using InABox.Configuration;
+using InABox.Core;
+using InABox.DynamicGrid;
+using InABox.Wpf;
+using InABox.WPF;
+
+namespace PRSDesktop;
+
+public class InvoiceBillLineGrid : InvoiceableGrid<BillLine>, ISpecificGrid
+{
+    protected override Expression<Func<BillLine, JobLink>> JobColumn => x => x.Job;
+
+    public InvoiceBillLineGrid()
+    {
+        ColumnsTag = "InvoiceExpenses";
+    }
+
+    protected override void DoReconfigure(DynamicGridOptions options)
+    {
+        base.DoReconfigure(options);
+
+        options.Clear();
+        options.EditRows = true;
+        options.RecordCount = true;
+        options.SelectColumns = true;
+        options.MultiSelect = true;
+        options.FilterRows = true;
+    }
+
+    protected override void Reload(
+    	Filters<BillLine> criteria, Columns<BillLine> columns, ref SortOrder<BillLine>? sort,
+    	CancellationToken token, Action<CoreTable?, Exception?> action)
+    {
+        var hasNothing = Filter<BillLine>.Where(x => x.Product.ID).IsEqualTo(Guid.Empty)
+            .And(x => x.OrderItem.ID).IsEqualTo(Guid.Empty);
+        var hasProduct = Filter<BillLine>.Where(x => x.Product.ID).IsNotEqualTo(Guid.Empty)
+            .And(x => x.Product.NonStock).IsEqualTo(true);
+        var hasOrderItem = Filter<BillLine>.Where(x => x.OrderItem.ID).IsNotEqualTo(Guid.Empty)
+            .And(x => x.OrderItem.Product.NonStock).IsEqualTo(true);
+        criteria.Add(hasNothing.Or(hasProduct).Or(hasOrderItem));
+
+        base.Reload(criteria, columns, ref sort, token, action);
+    }
+}

+ 18 - 13
prs.desktop/Panels/Invoices/InvoiceCalculationSelector.xaml

@@ -4,43 +4,48 @@
         xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
         xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
         xmlns:local="clr-namespace:PRSDesktop"
+        xmlns:shared="clr-namespace:PRS.Shared;assembly=PRS.Shared"
         mc:Ignorable="d"
         Title="Invoice Calculation" SizeToContent="WidthAndHeight" WindowStartupLocation="CenterScreen">
     <Grid Margin="5">
         <Grid.ColumnDefinitions>
             <ColumnDefinition Width="140"/>
             <ColumnDefinition Width="140"/>
+            <ColumnDefinition Width="140"/>
         </Grid.ColumnDefinitions>
         <Grid.RowDefinitions>
             <RowDefinition Height="Auto"/>
             <RowDefinition Height="*"/>
             <RowDefinition Height="Auto"/>
         </Grid.RowDefinitions>
-        
+
+        <!-- Time -->
         <Border Grid.Row="0" Grid.Column="0" Margin="0,0,2.5,0" BorderBrush="Gray" BorderThickness="0.75,0.75,0.75,0" Background="WhiteSmoke">
             <Label Content="Time" HorizontalContentAlignment="Center"/>
         </Border>
         <Border Grid.Row="1" Grid.Column="0" Margin="0,0,2.5,0" BorderBrush="Gray" BorderThickness="0.75" Padding="10,5,10,5">
-            <StackPanel Orientation="Vertical">
-                <RadioButton x:Name="LabourDetails" GroupName="Time" Content="Detailed" Margin="0,5,0,5" IsChecked="True"/>
-                <RadioButton x:Name="LabourActivity" GroupName="Time" Content="Activity" Margin="0,5,0,5"/>
-                <RadioButton x:Name="LabourCollapsed" GroupName="Time" Content="Collapsed" Margin="0,5,0,5"/>
-            </StackPanel>
+            <StackPanel Name="Labour" Orientation="Vertical"/>
         </Border>
         
+        <!-- Materials -->
         <Border Grid.Row="0" Grid.Column="1" Margin="2.5,0,0,0" BorderBrush="Gray" BorderThickness="0.75,0.75,0.75,0" Background="WhiteSmoke">
             <Label Content="Materials" HorizontalContentAlignment="Center"/>
         </Border>
         <Border Grid.Row="1" Grid.Column="1" Margin="2.5,0,0,0" BorderBrush="Gray" BorderThickness="0.75" Padding="10,5,10,5">
-            <StackPanel Orientation="Vertical" >
-                <RadioButton x:Name="PartsDetails" GroupName="Materials" Content="Detailed" Margin="0,5,0,5" IsChecked="True"/>
-                <RadioButton x:Name="PartsProduct" GroupName="Materials" Content="Product Code" Margin="0,5,0,5"/>
-                <RadioButton x:Name="PartsCostCentre" GroupName="Materials" Content="Cost Centre" Margin="0,5,0,5"/>
-                <RadioButton x:Name="PartsCollapsed" GroupName="Materials" Content="Collapsed" Margin="0,5,0,5"/>
-            </StackPanel>
+            <StackPanel Name="Parts" Orientation="Vertical"/>
+        </Border>
+        
+        <!-- Expenses -->
+        <Border Grid.Row="0" Grid.Column="2" Margin="2.5,0,0,0" BorderBrush="Gray" BorderThickness="0.75,0.75,0.75,0" Background="WhiteSmoke">
+            <Label Content="Expenses" HorizontalContentAlignment="Center"/>
+        </Border>
+        <Border Grid.Row="1" Grid.Column="2" Margin="2.5,0,0,0" BorderBrush="Gray" BorderThickness="0.75" Padding="10,5,10,5">
+            <StackPanel Name="Expenses" Orientation="Vertical"/>
         </Border>
         
-        <DockPanel Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2" LastChildFill="False" Margin="0,5,0,0">
+        <!-- Expenses -->
+        
+        <DockPanel Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3" LastChildFill="False" Margin="0,5,0,0">
             <Button x:Name="OK" DockPanel.Dock="Right" Width="80" Height="30" Content="OK" Click="OK_OnClick"/>
             <Button x:Name="Cancel" DockPanel.Dock="Right" Width="80" Height="30" Content="Cancel" Margin="0,0,5,0" Click="Cancel_OnClick"/>
         </DockPanel>

+ 101 - 63
prs.desktop/Panels/Invoices/InvoiceCalculationSelector.xaml.cs

@@ -1,79 +1,117 @@
+using System;
+using System.ComponentModel;
+using System.Linq.Expressions;
+using System.Runtime.CompilerServices;
 using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Documents;
+using InABox.Wpf;
+using InABox.WPF;
 using PRS.Shared;
 
-namespace PRSDesktop
+namespace PRSDesktop;
+
+public partial class InvoiceCalculationSelector : Window, INotifyPropertyChanged
 {
-    public partial class InvoiceCalculationSelector : Window
-    {
 
-        public InvoiceTimeCalculation TimeCalculation
+    private InvoiceTimeCalculation _timeCalculation;
+    public InvoiceTimeCalculation TimeCalculation
+    {
+        get => _timeCalculation;
+        set
         {
-            get => LabourDetails.IsChecked == true
-                ? InvoiceTimeCalculation.Detailed
-                : LabourActivity.IsChecked == true
-                    ? InvoiceTimeCalculation.Activity
-                    : InvoiceTimeCalculation.Collapsed;
-            
-            set
-            {
-                switch (value)
-                {
-                   case InvoiceTimeCalculation.Detailed :
-                        LabourDetails.IsChecked = true;
-                        break;
-                   case InvoiceTimeCalculation.Activity :
-                       LabourActivity.IsChecked = true;
-                       break;
-                   case InvoiceTimeCalculation.Collapsed :
-                       LabourCollapsed.IsChecked = true;
-                       break;
-                }
-            }
-        } 
-        
-        public InvoiceMaterialCalculation MaterialCalculation
+            _timeCalculation = value;
+            OnPropertyChanged();
+        }
+    }
+    
+    private InvoiceMaterialCalculation _materialCalculation;
+    public InvoiceMaterialCalculation MaterialCalculation
+    {
+        get => _materialCalculation;
+        set
         {
-            get => PartsDetails.IsChecked == true
-                ? InvoiceMaterialCalculation.Detailed
-                : PartsProduct.IsChecked == true
-                    ? InvoiceMaterialCalculation.Product
-                    : PartsCostCentre.IsChecked == true
-                        ? InvoiceMaterialCalculation.CostCentre
-                        : InvoiceMaterialCalculation.Collapsed;
-            
-            set
-            {
-                switch (value)
-                {
-                    case InvoiceMaterialCalculation.Detailed :
-                        PartsDetails.IsChecked = true;
-                        break;
-                    case InvoiceMaterialCalculation.Product :
-                        PartsProduct.IsChecked = true;
-                        break;
-                    case InvoiceMaterialCalculation.CostCentre :
-                        PartsCostCentre.IsChecked = true;
-                        break;
-                    case InvoiceMaterialCalculation.Collapsed :
-                        PartsCollapsed.IsChecked = true;
-                        break;
-                }
-            }
-        } 
-        
-        public InvoiceCalculationSelector()
+            _materialCalculation = value;
+            OnPropertyChanged();
+        }
+    }
+    
+    private InvoiceExpensesCalculation _expensesCalculation;
+    public InvoiceExpensesCalculation ExpensesCalculation
+    {
+        get => _expensesCalculation;
+        set
         {
-            InitializeComponent();
+            _expensesCalculation = value;
+            OnPropertyChanged();
         }
+    }
+
+    private static readonly (InvoiceTimeCalculation Calculation, string Display)[] TimeCalculations = [
+        (InvoiceTimeCalculation.Detailed, "Detailed"),
+        (InvoiceTimeCalculation.Activity, "Activity"),
+        (InvoiceTimeCalculation.Collapsed, "Collapsed"),
+        ];
+
+    private static readonly (InvoiceMaterialCalculation Calculation, string Display)[] MaterialCalculations = [
+        (InvoiceMaterialCalculation.Detailed, "Detailed"),
+        (InvoiceMaterialCalculation.Product, "Product Code"),
+        (InvoiceMaterialCalculation.CostCentre, "Cost Centre"),
+        (InvoiceMaterialCalculation.Collapsed, "Collapsed"),
+        ];
+
+    private static readonly (InvoiceExpensesCalculation Calculation, string Display)[] ExpensesCalculations = [
+        (InvoiceExpensesCalculation.Detailed, "Detailed"),
+        (InvoiceExpensesCalculation.Collapsed, "Collapsed"),
+        ];
+
+    public event PropertyChangedEventHandler? PropertyChanged;
+
+    public InvoiceCalculationSelector()
+    {
+        InitializeComponent();
 
-        private void OK_OnClick(object sender, RoutedEventArgs e)
+        foreach(var (value, display) in TimeCalculations)
         {
-            DialogResult = true;
+            Labour.Children.Add(CreateRadioButton(value, display, x => x.TimeCalculation, () => TimeCalculation));
         }
-
-        private void Cancel_OnClick(object sender, RoutedEventArgs e)
+        foreach(var (value, display) in MaterialCalculations)
+        {
+            Parts.Children.Add(CreateRadioButton(value, display, x => x.MaterialCalculation, () => MaterialCalculation));
+        }
+        foreach(var (value, display) in ExpensesCalculations)
         {
-            DialogResult = false;
+            Expenses.Children.Add(CreateRadioButton(value, display, x => x.ExpensesCalculation, () => ExpensesCalculation));
         }
     }
+
+    private RadioButton CreateRadioButton(Enum value, string display, Expression<Func<InvoiceCalculationSelector, Enum>> property, Func<Enum> currentValue)
+    {
+        var button = new RadioButton
+        {
+            GroupName = value.GetType().Name,
+            Content = display,
+            Margin = new(0, 5, 0, 5),
+            Tag = value
+        };
+        button.Bind(RadioButton.IsCheckedProperty, this, property, converter: new FuncConverter<Enum, bool>(
+            x => Equals(x, value),
+            x => x ? value : currentValue()));
+        return button;
+    }
+
+    private void OK_OnClick(object sender, RoutedEventArgs e)
+    {
+        DialogResult = true;
+    }
+
+    private void Cancel_OnClick(object sender, RoutedEventArgs e)
+    {
+        DialogResult = false;
+    }
+
+    protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
+    {
+        PropertyChanged?.Invoke(this, new(propertyName));
+    }
 }

+ 1 - 0
prs.desktop/Panels/Invoices/InvoiceGrid.cs

@@ -63,6 +63,7 @@ namespace PRSDesktop
             AddButton("Print", PRSDesktop.Resources.printer.AsBitmapImage(), PrintInvoice2);
             AddButton("Email", PRSDesktop.Resources.email.AsBitmapImage(), EmailInvoice2);
             HiddenColumns.Add(x => x.CustomerLink.ID);
+            HiddenColumns.Add(x => x.CustomerLink.Markup);
             HiddenColumns.Add(x => x.JobLink.ID);
             HiddenColumns.Add(x => x.SellGL.ID);
 

+ 32 - 10
prs.desktop/Panels/Invoices/InvoiceLineGrid.cs

@@ -31,41 +31,63 @@ namespace PRSDesktop
             options.MultiSelect = true;
         }
 
-        public Invoice Invoice { get; set; }
+        public Invoice? Invoice { get; set; }
 
         private bool CalculateLines(Button sender, CoreRow[] rows)
         {
+            if(Invoice is null)
+            {
+                MessageBox.Show("Please Select or Create an Invoice First!");
+                return false;
+            }
             InvoiceCalculationSelector selector = new InvoiceCalculationSelector()
             {
                 TimeCalculation = InvoiceTimeCalculation.Activity,
-                MaterialCalculation = InvoiceMaterialCalculation.Product
+                MaterialCalculation = InvoiceMaterialCalculation.Product,
+                ExpensesCalculation = InvoiceExpensesCalculation.Detailed
             };
             if (selector.ShowDialog() == true)
             {
                 var time = selector.TimeCalculation;
                 var materials = selector.MaterialCalculation;
-                Progress.ShowModal("Calculating Invoice", progress => InvoiceUtilities.GenerateInvoiceLines(Invoice.ID, time, materials, progress));
+                var expenses = selector.ExpensesCalculation;
+                Progress.ShowModal("Calculating Invoice", progress => InvoiceUtilities.GenerateInvoiceLines(Invoice.ID, time, materials, expenses, progress));
                 return true;
             }
-
-            MessageBox.Show("Please Select or Create an Invoice First!");
-            return false;
+            else
+            {
+                return false;
+            }
         }
 
         protected override void Reload(
         	Filters<InvoiceLine> criteria, Columns<InvoiceLine> columns, ref SortOrder<InvoiceLine>? sort,
         	CancellationToken token, Action<CoreTable?, Exception?> action)
         {
-            criteria.Add(Filter<InvoiceLine>.Where(x => x.InvoiceLink.ID).IsEqualTo(Invoice.ID));
+            if(Invoice is null)
+            {
+                criteria.Add(Filter.None<InvoiceLine>());
+            }
+            else
+            {
+                criteria.Add(Filter<InvoiceLine>.Where(x => x.InvoiceLink.ID).IsEqualTo(Invoice.ID));
+            }
             base.Reload(criteria, columns, ref sort, token, action);
         }
 
+        protected override bool CanCreateItems()
+        {
+            return Invoice is not null;
+        }
+
         public override InvoiceLine CreateItem()
         {
             var result = base.CreateItem();
-            result.InvoiceLink.ID = Invoice.ID;
-            result.InvoiceLink.Synchronise(Invoice.ID);
-            result.SellGL.ID = Invoice.SellGL.ID;
+            if(Invoice is not null)
+            {
+                result.InvoiceLink.CopyFrom(Invoice);
+                result.SellGL.ID = Invoice.SellGL.ID;
+            }
             return result;
         }
     }

+ 11 - 6
prs.desktop/Panels/Invoices/InvoicePanel.xaml

@@ -8,13 +8,15 @@
              mc:Ignorable="d"
              d:DesignHeight="600" d:DesignWidth="800">
     <dynamicGrid:DynamicSplitPanel 
+        Name="SplitPanel"
         Anchor="Detail" 
         AnchorWidth="600" 
         View="Combined" 
         AllowableViews="Combined, Master" 
         DetailCaption="Invoice Details" 
         MasterCaption="Invoice List"
-        DetailHeight="300">
+        DetailHeight="300"
+        OnChanged="DynamicSplitPanel_OnChanged">
         <dynamicGrid:DynamicSplitPanel.Header>
             <Border BorderThickness="0.75" BorderBrush="Gray" Background="WhiteSmoke" Padding="5,0">
                 <!-- <DockPanel> -->
@@ -46,17 +48,20 @@
             </Border>
         </dynamicGrid:DynamicSplitPanel.DetailHeader>
         <dynamicGrid:DynamicSplitPanel.Detail>
-            <local:InvoiceLineGrid x:Name="Lines" Grid.Column="1" Grid.Row="0" Grid.ColumnSpan="2"/>
-        </dynamicGrid:DynamicSplitPanel.Detail>
-        <dynamicGrid:DynamicSplitPanel.SecondaryDetail>
-            <dynamicGrid:DynamicTabControl TabStripPlacement="Top">
+            <dynamicGrid:DynamicTabControl TabStripPlacement="Bottom">
+                <dynamicGrid:DynamicTabItem Header="Items">
+                    <local:InvoiceLineGrid x:Name="Lines" Grid.Column="1" Grid.Row="0" Grid.ColumnSpan="2"/>
+                </dynamicGrid:DynamicTabItem>
                 <dynamicGrid:DynamicTabItem Header="Time">
                     <local:InvoiceAssignmentGrid x:Name="Time"/>
                 </dynamicGrid:DynamicTabItem>
                 <dynamicGrid:DynamicTabItem Header="Materials">
                     <local:InvoiceStockMovementGrid x:Name="Parts"/>
                 </dynamicGrid:DynamicTabItem>
+                <dynamicGrid:DynamicTabItem Header="Expenses">
+                    <local:InvoiceBillLineGrid x:Name="Bills"/>
+                </dynamicGrid:DynamicTabItem>
             </dynamicGrid:DynamicTabControl>
-        </dynamicGrid:DynamicSplitPanel.SecondaryDetail>
+        </dynamicGrid:DynamicSplitPanel.Detail>
     </dynamicGrid:DynamicSplitPanel>
 </UserControl>

+ 133 - 67
prs.desktop/Panels/Invoices/InvoicePanel.xaml.cs

@@ -13,89 +13,155 @@ using InABox.Core.Postable;
 using InABox.DynamicGrid;
 using InABox.Wpf;
 
-namespace PRSDesktop
+namespace PRSDesktop;
+
+public enum InvoicingStrategy
+{
+    InvoiceOnIssue,
+    InvoiceOnPurchase
+}
+
+public class InvoicePanelSettings : BaseObject, IGlobalConfigurationSettings
+{
+    [EditorSequence(1)]
+    public InvoicingStrategy InvoicingStrategy { get; set; }
+}
+
+public class InvoicePanelUserSettings : BaseObject, IUserConfigurationSettings
+{
+    public DynamicSplitPanelView View { get; set; } = DynamicSplitPanelView.Combined;
+    public double AnchorWidth { get; set; } = 500;
+}
+
+/// <summary>
+///     Interaction logic for InvoiceGrid.xaml
+/// </summary>
+public partial class InvoicePanel : UserControl, IPanel<Invoice>, IMasterDetailControl<Job>
 {
+
+    private InvoicePanelSettings _settings;
+
+    private InvoicePanelUserSettings _userSettings;
+
+    private InvoiceableGridSettings _gridSettings;
     
-    /// <summary>
-    ///     Interaction logic for InvoiceGrid.xaml
-    /// </summary>
-    public partial class InvoicePanel : UserControl, IPanel<Invoice>, IMasterDetailControl<Job>
+    public InvoicePanel()
     {
-        
-        public InvoicePanel()
-        {
-            InitializeComponent();
-            Invoices.OnSelectItem += Invoices_OnSelectItem;
-        }
+        InitializeComponent();
+        Invoices.OnSelectItem += Invoices_OnSelectItem;
 
-        public Job? Master
-        {
-            get => Invoices.Master;
-            set => Invoices.Master = value;
-        }
-        
-        public bool IsReady { get; set; }
+        _settings = new GlobalConfiguration<InvoicePanelSettings>().Load();
+        _userSettings = UserConfiguration.Load<InvoicePanelUserSettings>();
+        _gridSettings = UserConfiguration.Load<InvoiceableGridSettings>();
 
-        public event DataModelUpdateEvent? OnUpdateDataModel;
+        Time.InvoiceGridSettings = _gridSettings;
+        Parts.InvoiceGridSettings = _gridSettings;
+        Bills.InvoiceGridSettings = _gridSettings;
 
-        public Dictionary<string, object[]> Selected()
-        {
-            return new Dictionary<string, object[]> { { typeof(Invoice).EntityName(), Invoices.SelectedRows } };
-        }
+        _gridSettings.PropertyChanged += GridSettings_PropertyChanged;
 
-        public void CreateToolbarButtons(IPanelHost host)
-        {
-            AccountsSetupActions.Standard(host);
+        SplitPanel.View = _userSettings.View;
+        SplitPanel.AnchorWidth = _userSettings.AnchorWidth;
+    }
 
-            PostUtils.CreateToolbarButtons(
-                host,
-                () => (DataModel(Selection.Selected) as IDataModel<Invoice>)!,
-                () => Invoices.Refresh(false, true),
-                true);
-        }
+    private void GridSettings_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+    {
+        UserConfiguration.Save(_gridSettings);
+    }
 
-        public void Setup()
-        {
-            Invoices.Refresh(true, false);
-            Parts.Refresh(true, false);
-            Time.Refresh(true, false);
-            Lines.Refresh(true, false);
-        }
+    public Job? Master
+    {
+        get => Invoices.Master;
+        set => Invoices.Master = value;
+    }
+    
+    public bool IsReady { get; set; }
 
-        public void Shutdown(CancelEventArgs? cancel)
-        {
-        }
+    public event DataModelUpdateEvent? OnUpdateDataModel;
 
-        public void Refresh()
-        {
-            Invoices.Refresh(false, true);
-        }
+    public Dictionary<string, object[]> Selected()
+    {
+        return new Dictionary<string, object[]> { { typeof(Invoice).EntityName(), Invoices.SelectedRows } };
+    }
 
-        public string SectionName => "Invoice Grid";
+    public void CreateToolbarButtons(IPanelHost host)
+    {
+        AccountsSetupActions.Standard(host);
 
-        public DataModel DataModel(Selection selection)
+        host.CreateSetupSeparator();
+        
+        host.CreateSetupAction(new PanelAction()
         {
-            var ids = Invoices.ExtractValues(x => x.ID, selection).ToArray();
-            return new InvoiceDataModel(Filter<Invoice>.Where(x => x.ID).InList(ids));
-        }
+            Caption = "Invoices Settings", 
+            Image = PRSDesktop.Resources.edit, 
+            OnExecute = action =>
+            {
+                if (DynamicGridUtils.EditObject(_settings))
+                {
+                    new GlobalConfiguration<InvoicePanelSettings>().Save(_settings);
+                    Parts.InvoiceSettings = _settings;
+                }
+            } 
+        });
 
-        public void Heartbeat(TimeSpan time)
-        {
-        }
+        PostUtils.CreateToolbarButtons(
+            host,
+            () => (DataModel(Selection.Selected) as IDataModel<Invoice>)!,
+            () => Invoices.Refresh(false, true),
+            true);
+    }
 
-        private void Invoices_OnSelectItem(object sender, DynamicGridSelectionEventArgs e)
-        {
-            var _invoice = e.Rows?.FirstOrDefault()?.ToObject<Invoice>() ?? new Invoice();
-            
-            Parts.Invoice = _invoice;
-            Parts.Refresh(false, true);
-            
-            Time.Invoice = _invoice;
-            Time.Refresh(false, true);
-
-            Lines.Invoice = _invoice;
-            Lines.Refresh(false, true);
-        }
+    public void Setup()
+    {
+        Invoices.Refresh(true, false);
+        Parts.Refresh(true, false);
+        Time.Refresh(true, false);
+        Lines.Refresh(true, false);
+        Bills.Refresh(true, false);
+    }
+
+    public void Shutdown(CancelEventArgs? cancel)
+    {
+    }
+
+    public void Refresh()
+    {
+        Invoices.Refresh(false, true);
+    }
+
+    public string SectionName => "Invoice Grid";
+
+    public DataModel DataModel(Selection selection)
+    {
+        var ids = Invoices.ExtractValues(x => x.ID, selection).ToArray();
+        return new InvoiceDataModel(Filter<Invoice>.Where(x => x.ID).InList(ids));
+    }
+
+    public void Heartbeat(TimeSpan time)
+    {
+    }
+
+    private void Invoices_OnSelectItem(object sender, DynamicGridSelectionEventArgs e)
+    {
+        var _invoice = e.Rows?.FirstOrDefault()?.ToObject<Invoice>();
         
+        Parts.Invoice = _invoice;
+        Parts.Refresh(false, true);
+        
+        Time.Invoice = _invoice;
+        Time.Refresh(false, true);
+
+        Lines.Invoice = _invoice;
+        Lines.Refresh(false, true);
+
+        Bills.Invoice = _invoice;
+        Bills.Refresh(false, true);
+    }
+
+    private void DynamicSplitPanel_OnChanged(object sender, DynamicSplitPanelSettings e)
+    {
+        _userSettings.View = SplitPanel.View;
+        _userSettings.AnchorWidth = SplitPanel.AnchorWidth;
+        new UserConfiguration<InvoicePanelUserSettings>().Save(_userSettings);
     }
 }

+ 46 - 118
prs.desktop/Panels/Invoices/InvoiceStockMovementGrid.cs

@@ -1,41 +1,44 @@
 using System;
+using System.Collections.Generic;
 using System.Linq;
+using System.Linq.Expressions;
 using System.Threading;
 using System.Windows;
 using System.Windows.Controls;
 using System.Windows.Media.Imaging;
 using Comal.Classes;
+using EnumsNET;
 using InABox.Clients;
+using InABox.Configuration;
 using InABox.Core;
 using InABox.DynamicGrid;
+using InABox.Wpf;
 using InABox.WPF;
 
 namespace PRSDesktop;
 
-public class InvoiceStockMovementGrid : DynamicDataGrid<StockMovement>, ISpecificGrid
+public class InvoiceStockMovementGrid : InvoiceableGrid<StockMovement>, ISpecificGrid
 {
-    private readonly BitmapImage chargeable_excluded = PRSDesktop.Resources.tick.Fade(0.8F).AsGrayScale().AsBitmapImage();
-    private readonly BitmapImage chargeable_included = PRSDesktop.Resources.tick.AsBitmapImage();
-    
-    private readonly BitmapImage unchargeable_excluded = PRSDesktop.Resources.warning.Fade(0.8F).AsGrayScale().AsBitmapImage();
-    private readonly BitmapImage unchargeable_included = PRSDesktop.Resources.warning.AsBitmapImage();
-    
-    private readonly Button IncludeButton;
-
-    private readonly Button ShowAllButton;
-    private bool _showall = false;
+    private InvoicePanelSettings _invoiceSettings;
+    public InvoicePanelSettings InvoiceSettings
+    {
+        get => _invoiceSettings;
+        set
+        {
+            _invoiceSettings = value;
+            Refresh(false, true);
+        }
+    }
 
+    protected override Expression<Func<StockMovement, JobLink>> JobColumn => x => x.Job;
+    
     public InvoiceStockMovementGrid()
     {
-        
-        ColumnsTag = "InvoiceParts";
+        _invoiceSettings = new GlobalConfiguration<InvoicePanelSettings>().Load();
 
-        HiddenColumns.Add(x => x.Invoice.ID);
+        ColumnsTag = "InvoiceParts";
 
-        ActionColumns.Add(new DynamicImageColumn(IncludeImage, IncludeOne));
-        AddEditButton("Exclude", chargeable_excluded, IncludeSelected, DynamicGridButtonPosition.Right);
-        IncludeButton = AddEditButton("Include", chargeable_included, IncludeSelected, DynamicGridButtonPosition.Right);
-        ShowAllButton = AddButton("Show All", null, ToggleShowAll);
+        HiddenColumns.Add(x => x.Transaction);
     }
 
     public override DynamicGridColumns GenerateColumns()
@@ -55,112 +58,37 @@ public class InvoiceStockMovementGrid : DynamicDataGrid<StockMovement>, ISpecifi
         options.FilterRows = true;
         options.PageSize = 1000;
     }
-    
-    public Invoice Invoice { get; set; }
-
-    private bool IncludeSelected(Button sender, CoreRow[] rows)
-    {
-        if (Invoice.ID != Guid.Empty)
-        {
-            using (new WaitCursor())
-            {
-                var bAdd = sender == IncludeButton;
-                var items = rows.ToArray<StockMovement>();
-                foreach (var item in items)
-                {
-                    if (bAdd)
-                    {
-                        item.Invoice.ID = Invoice.ID;
-                        item.Invoice.Synchronise(Invoice);
-                    }
-                    else
-                    {
-                        item.Invoice.ID = Guid.Empty;
-                        item.Invoice.Synchronise(new Invoice());
-                    }
-                }
-
-                Client.Save(items, bAdd ? "Added to Invoice" : "Removed From Invoice", (o, e) => { });
-                foreach (var row in rows)
-                {
-                    row.Set<StockMovement, Guid>(x => x.Invoice.ID, bAdd ? Invoice.ID : Guid.Empty);
-                    InvalidateRow(row);
-                }
-            }
-        }
-        else
-            MessageBox.Show("Please Select or Create an Invoice First!");
-        return false;
-    }
-
-    private bool IncludeOne(CoreRow? arg)
-    {
-        if (arg == null)
-            return false;
-        
-        if (Invoice.ID != Guid.Empty)
-        {
-            var id = arg.Get<StockMovement, Guid>(x => x.ID);
-            var item = arg.ToObject<StockMovement>();
-            if (item != null)
-            {
-                if (!item.Invoice.IsValid())
-                {
-                    item.Invoice.ID = Invoice.ID;
-                    item.Invoice.Synchronise(Invoice);
-                }
-                else
-                {
-                    item.Invoice.ID = Guid.Empty;
-                    item.Invoice.Synchronise(new Invoice());
-                }
-                Client.Save(item, "Added to Invoice", (o,e) => { });
-                arg.Set<StockMovement, Guid>(x => x.Invoice.ID, item.Invoice.ID);
-                InvalidateRow(arg);
-            }
-        }
-        else
-            MessageBox.Show("Please Select or Create an Invoice First!");
-        return false;
-    }
-
-    private BitmapImage IncludeImage(CoreRow? arg)
-    {
-        if (arg == null)
-            return chargeable_included;
-        bool chargeable = arg.Get<StockMovement, bool>(x => x.Charge.Chargeable);
-        bool included = Entity.IsEntityLinkValid<StockMovement, InvoiceLink>(x => x.Invoice, arg);
-        return chargeable
-            ? included 
-                ? chargeable_included 
-                : chargeable_excluded
-            : included
-                ? unchargeable_included
-                : unchargeable_excluded;
-    }
-    
-    private bool ToggleShowAll(Button arg1, CoreRow[] rows)
-    {
-        _showall = !_showall;
-        UpdateButton(ShowAllButton, null, _showall ? "Selected Only" : "Show All");
-        return true;
-    }
 
     protected override void Reload(
     	Filters<StockMovement> criteria, Columns<StockMovement> columns, ref SortOrder<StockMovement>? sort,
     	CancellationToken token, Action<CoreTable?, Exception?> action)
     {
-        if (Invoice.ID  == Guid.Empty)
-            criteria.Add(new Filter<StockMovement>().None());
-        else
+        if (Invoice is not null && Invoice.ID != Guid.Empty)
         {
-            criteria.Add(Filter<StockMovement>.Where(x => x.Job.ID).IsEqualTo(Invoice.JobLink.ID));
-            criteria.Add(Filter<StockMovement>.Where(x => x.Type).IsEqualTo(StockMovementType.Issue));
-            if (_showall)
-                criteria.Add(Filter<StockMovement>.Where(x => x.Invoice.ID).IsEqualTo(Invoice.ID).Or(x => x.Invoice).NotLinkValid());
-            else
-                criteria.Add(Filter<StockMovement>.Where(x => x.Invoice.ID).IsEqualTo(Invoice.ID));
-            
+            var job = Invoice.JobLink.ID;
+
+            switch (InvoiceSettings.InvoicingStrategy)
+            {
+                case InvoicingStrategy.InvoiceOnIssue:
+                    criteria.Add(Filter<StockMovement>.Where(x => x.Type).IsEqualTo(StockMovementType.Issue));
+                    break;
+                case InvoicingStrategy.InvoiceOnPurchase:
+                    var filterReceives = Filter<StockMovement>.Where(x => x.Type).IsEqualTo(StockMovementType.Receive);
+                    var filterTransferOuts = Filter<StockMovement>
+                        .Where(x => x.Type).IsEqualTo(StockMovementType.TransferOut)
+                        .And(x => x.TransferID).InQuery(
+                            Filter<StockMovement>.Where(x => x.Job.ID).IsNotEqualTo(job)
+                                .And(x => x.Type).IsEqualTo(StockMovementType.TransferIn),
+                            x => x.TransferID);
+                    var filterTransferIns = Filter<StockMovement>
+                        .Where(x => x.Type).IsEqualTo(StockMovementType.TransferIn)
+                        .And(x => x.TransferID).InQuery(
+                            Filter<StockMovement>.Where(x => x.Job.ID).IsNotEqualTo(job)
+                                .And(x => x.Type).IsEqualTo(StockMovementType.TransferOut),
+                            x => x.TransferID);
+                    criteria.Add(filterReceives.Or(filterTransferOuts).Or(filterTransferIns));
+                    break;
+            }
         }
         base.Reload(criteria, columns, ref sort, token, action);
     }

+ 296 - 0
prs.desktop/Panels/Invoices/InvoiceableGrid.cs

@@ -0,0 +1,296 @@
+using Comal.Classes;
+using InABox.Clients;
+using InABox.Configuration;
+using InABox.Core;
+using InABox.DynamicGrid;
+using InABox.Wpf;
+using InABox.WPF;
+using Org.BouncyCastle.Asn1.Mozilla;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media.Imaging;
+
+namespace PRSDesktop;
+
+public enum InvoiceItemFilterType
+{
+    [Caption("Only Selected")]
+    OnlySelected,
+    [Caption("Other Invoices")]
+    OtherInvoices,
+    [Caption("Uninvoiced")]
+    Uninvoiced,
+    [Caption("All")]
+    All
+}
+
+public class InvoiceableGridSettings : IUserConfigurationSettings, INotifyPropertyChanged
+{
+    private InvoiceItemFilterType _filter = InvoiceItemFilterType.Uninvoiced;
+    public InvoiceItemFilterType Filter
+    {
+        get => _filter;
+        set
+        {
+            _filter = value;
+            OnPropertyChanged();
+        }
+    }
+
+    private bool _onlyChargeable = false;
+    public bool OnlyChargeable
+    {
+        get => _onlyChargeable;
+        set
+        {
+            _onlyChargeable = value;
+            OnPropertyChanged();
+        }
+    }
+
+    public event PropertyChangedEventHandler? PropertyChanged;
+
+    protected void OnPropertyChanged([CallerMemberName] string? property = null)
+    {
+        PropertyChanged?.Invoke(this, new(property));
+    }
+}
+
+public abstract class InvoiceableGrid<T> : DynamicDataGrid<T>, INotifyPropertyChanged
+    where T : Entity, IInvoiceable, IRemotable, IPersistent, new()
+{
+    private readonly BitmapImage chargeable_excluded = PRSDesktop.Resources.tick.Fade(0.8F).AsGrayScale().AsBitmapImage();
+    private readonly BitmapImage chargeable_included = PRSDesktop.Resources.tick.AsBitmapImage();
+    
+    private readonly BitmapImage unchargeable_excluded = PRSDesktop.Resources.warning.Fade(0.8F).AsGrayScale().AsBitmapImage();
+    private readonly BitmapImage unchargeable_included = PRSDesktop.Resources.warning.AsBitmapImage();
+
+    private Button IncludeButton = null!;
+
+    public Invoice? Invoice { get; set; }
+
+    protected abstract Expression<Func<T, JobLink>> JobColumn { get; }
+
+    private InvoiceableGridSettings _invoiceGridSettings = new();
+
+    public event PropertyChangedEventHandler? PropertyChanged;
+
+    public InvoiceableGridSettings InvoiceGridSettings
+    {
+        get => _invoiceGridSettings;
+        set
+        {
+            _invoiceGridSettings = value;
+            OnPropertyChanged();
+        }
+    }
+
+    public InvoiceableGrid()
+    {
+        HiddenColumns.Add(x => x.Invoice.ID);
+        HiddenColumns.Add(x => x.Invoice.Number);
+        HiddenColumns.Add(x => x.Charge.Chargeable);
+
+        ActionColumns.Add(new DynamicImageColumn(IncludeImage, IncludeOne)
+        {
+            ToolTip = IncludeToolTip
+        });
+        AddEditButton("Exclude", chargeable_excluded, IncludeSelected, DynamicGridButtonPosition.Right);
+        IncludeButton = AddEditButton("Include", chargeable_included, IncludeSelected, DynamicGridButtonPosition.Right);
+
+        var comboBox = new ComboBox
+        {
+            VerticalContentAlignment = VerticalAlignment.Center,
+            ItemsSource = Enum.GetValues<InvoiceItemFilterType>()
+                .Select(x => new KeyValuePair<InvoiceItemFilterType, string>(x, x.GetCaption()))
+                .ToArray(),
+            SelectedValuePath = "Key",
+            DisplayMemberPath = "Value"
+        };
+        comboBox.Bind(ComboBox.SelectedValueProperty, this, x => x.InvoiceGridSettings.Filter);
+        comboBox.SelectionChanged += ComboBox_SelectionChanged;
+        AddFrameworkElement(comboBox);
+
+        var checkBox = new CheckBox
+        {
+            Content = "Only Chargeable?",
+            VerticalContentAlignment = VerticalAlignment.Center,
+        };
+        checkBox.Bind(CheckBox.IsCheckedProperty, this, x => x.InvoiceGridSettings.OnlyChargeable);
+        checkBox.Checked += CheckBox_Checked;
+        checkBox.Unchecked += CheckBox_Unchecked;
+        AddFrameworkElement(checkBox);
+    }
+
+    private void CheckBox_Unchecked(object sender, RoutedEventArgs e)
+    {
+        if (!IsReady) return;
+        Refresh(false, true);
+    }
+
+    private void CheckBox_Checked(object sender, RoutedEventArgs e)
+    {
+        if (!IsReady) return;
+        Refresh(false, true);
+    }
+
+    private void ComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
+    {
+        if (!IsReady) return;
+        Refresh(false, true);
+    }
+
+    private bool IncludeSelected(Button sender, CoreRow[] rows)
+    {
+        if (Invoice is not null && Invoice.ID != Guid.Empty)
+        {
+            using (new WaitCursor())
+            {
+                var bAdd = sender == IncludeButton;
+                var items = rows.ToArray<T>();
+                foreach (var item in items)
+                {
+                    if (item.Invoice.ID != Guid.Empty && item.Invoice.ID != Invoice.ID) continue;
+
+                    if (bAdd)
+                    {
+                        item.Invoice.CopyFrom(Invoice);
+                    }
+                    else
+                    {
+                        item.Invoice.ID = Guid.Empty;
+                        item.Invoice.Clear();
+                    }
+                }
+
+                Client.Save(items, bAdd ? "Added to Invoice" : "Removed From Invoice");
+                return true;
+            }
+        }
+        else
+            MessageWindow.ShowMessage("Please Select or Create an Invoice First!", "No invoice selected");
+        return false;
+    }
+
+    private bool IncludeOne(CoreRow? arg)
+    {
+        if (arg == null)
+            return false;
+        
+        if (Invoice is not null && Invoice.ID != Guid.Empty)
+        {
+            var invoiceID = arg.Get<T, Guid>(x => x.Invoice.ID);
+            if(invoiceID != Guid.Empty && invoiceID != Invoice.ID)
+            {
+                return false;
+            }
+
+            var item = arg.ToObject<T>();
+            if (!item.Invoice.IsValid())
+            {
+                item.Invoice.ID = Invoice.ID;
+                item.Invoice.Synchronise(Invoice);
+            }
+            else
+            {
+                item.Invoice.ID = Guid.Empty;
+                item.Invoice.Synchronise(new Invoice());
+            }
+            Client.Save(item, "Added to Invoice");
+            return true;
+        }
+        else
+            MessageWindow.ShowMessage("Please Select or Create an Invoice First!", "No invoice selected");
+        return false;
+    }
+
+    private BitmapImage? IncludeImage(CoreRow? arg)
+    {
+        if (arg == null)
+            return chargeable_included;
+        if (Invoice is null) return null;
+
+        var invoiceID = arg.Get<T, Guid>(x => x.Invoice.ID);
+        if(invoiceID == Guid.Empty)
+        {
+            return null;
+        }
+        var included = invoiceID == Invoice.ID;
+        var chargeable = arg.Get<T, bool>(x => x.Charge.Chargeable);
+        return chargeable
+            ? included 
+                ? chargeable_included 
+                : chargeable_excluded
+            : included
+                ? unchargeable_included
+                : unchargeable_excluded;
+    }
+
+    private FrameworkElement? IncludeToolTip(DynamicActionColumn column, CoreRow? row)
+    {
+        if (row == null || Invoice is null)
+            return null;
+
+        var invoiceID = row.Get<T, Guid>(x => x.Invoice.ID);
+        if(invoiceID == Guid.Empty)
+        {
+            return column.TextToolTip("Not invoiced.");
+        }
+
+        var chargeable = row.Get<T, bool>(x => x.Charge.Chargeable);
+        
+        var included = invoiceID == Invoice.ID;
+        if (included)
+        {
+            return chargeable
+                ? column.TextToolTip("Included on this invoice")
+                : column.TextToolTip("Included on this invoice; not chargeable");
+        }
+        else
+        {
+            var invoiceNumber = row.Get<T, int>(x => x.Invoice.Number);
+            return chargeable
+                ? column.TextToolTip($"Included on invoice {invoiceNumber}")
+                : column.TextToolTip($"Included on invoice {invoiceNumber}; not chargeable");
+        }
+    }
+
+    protected override void Reload(Filters<T> criteria, Columns<T> columns, ref SortOrder<T>? sort, CancellationToken token, Action<CoreTable?, Exception?> action)
+    {
+        if (Invoice is null || Invoice.ID == Guid.Empty)
+            criteria.Add(new Filter<T>().None());
+        else
+        {
+            criteria.Add(Filter<T>
+                .Where<Guid>(CoreUtils.GetFullPropertyName(JobColumn, ".") + ".ID").IsEqualTo(Invoice.JobLink.ID));
+            criteria.Add(InvoiceGridSettings.Filter switch
+            {
+                InvoiceItemFilterType.OnlySelected => Filter<T>.Where(x => x.Invoice.ID).IsEqualTo(Invoice.ID),
+                InvoiceItemFilterType.OtherInvoices => Filter<T>.Where(x => x.Invoice.ID).IsNotEqualTo(Guid.Empty),
+                InvoiceItemFilterType.Uninvoiced => Filter<T>.Where(x => x.Invoice.ID).IsEqualTo(Invoice.ID)
+                    .Or(x => x.Invoice.ID).IsEqualTo(Guid.Empty),
+                InvoiceItemFilterType.All => Filter.All<T>(),
+                _ => Filter.None<T>()
+            });
+            if (InvoiceGridSettings.OnlyChargeable)
+            {
+                criteria.Add(Filter<T>.Where(x => x.Charge.Chargeable).IsEqualTo(true));
+            }
+        }
+        base.Reload(criteria, columns, ref sort, token, action);
+    }
+
+    protected void OnPropertyChanged([CallerMemberName] string? property = null)
+    {
+        PropertyChanged?.Invoke(this, new(property));
+    }
+}

+ 345 - 238
prs.shared/Utilities/InvoiceUtilities.cs

@@ -1,281 +1,388 @@
 using Comal.Classes;
 using InABox.Clients;
 using InABox.Core;
+using PRSDimensionUtils;
 
-namespace PRS.Shared
+namespace PRS.Shared;
+
+
+public enum InvoiceTimeCalculation
 {
+    Detailed,
+    Activity,
+    Collapsed,
+}
     
-    public enum InvoiceTimeCalculation
-    {
-        Detailed,
-        Activity,
-        Collapsed,
-    }
-        
-    public enum InvoiceMaterialCalculation
+public enum InvoiceMaterialCalculation
+{
+    Detailed,
+    Product,
+    CostCentre,
+    Collapsed,
+}
+    
+public enum InvoiceExpensesCalculation
+{
+    Detailed,
+    Collapsed,
+}
+
+public static class InvoiceUtilities
+{
+    
+    private class InvoiceLineDetail
     {
-        Detailed,
-        Product,
-        CostCentre,
-        Collapsed,
+        public String Description { get; set; }
+        public TaxCodeLink TaxCode { get; set; }
+        public double Quantity { get; set; }
+        public double Charge { get; set; }
+
+        public InvoiceLineDetail()
+        {
+            TaxCode = new TaxCodeLink();
+        }
     }
-    
-    public static class InvoiceUtilities
+
+    private static async Task<InvoiceLine[]> TimeLines(Invoice invoice, InvoiceTimeCalculation timesummary)
     {
-        
-        private class InvoiceLineDetail
+        var timelines = new Dictionary<Guid, InvoiceLineDetail>();
+
+        var activitiesTask = Task.Run(() =>
+        {
+            return Client.Query(
+                Filter<CustomerActivitySummary>.Where(x => x.Customer.ID).InList(invoice.CustomerLink.ID, Guid.Empty),
+                Columns.None<CustomerActivitySummary>().Add(x => x.Customer.ID)
+                    .Add(x => x.Activity.ID)
+                    .Add(x => x.Activity.Code)
+                    .Add(x => x.Activity.Description)
+                    .Add(x => x.Charge.TaxCode.ID)
+                    .Add(x => x.Charge.TaxCode.Rate)
+                    .Add(x => x.Charge.Chargeable)
+                    .Add(x => x.Charge.FixedCharge)
+                    .Add(x => x.Charge.ChargeRate)
+                    .Add(x => x.Charge.ChargePeriod)
+                    .Add(x => x.Charge.MinimumCharge))
+                .ToObjects<CustomerActivitySummary>()
+                .GroupByDictionary(x => (CustomerID: x.Customer.ID, ActivityID: x.Activity.ID));
+        });
+        var assignmentsTask = Task.Run(() =>
         {
-            public Guid ID { get; set; }
-            public String Description { get; set; }
-            public TaxCodeLink TaxCode { get; set; }
-            public double Quantity { get; set; }
-            public double Charge { get; set; }
+            return Client.Query(
+                Filter<Assignment>.Where(x => x.Invoice.ID).IsEqualTo(invoice.ID).And(x => x.Charge.Chargeable).IsEqualTo(true),
+                Columns.None<Assignment>()
+                    .Add(x => x.ID)
+                    .Add(x => x.ActivityLink.ID)
+                    .Add(x => x.ActivityLink.Description)
+                    .Add(x => x.Date)
+                    .Add(x => x.Description)
+                    .Add(x => x.Charge.OverrideCharge)
+                    .Add(x => x.Charge.Charge)
+                    .Add(x => x.Charge.OverrideQuantity)
+                    .Add(x => x.Charge.Quantity)
+                    .Add(x => x.Actual.Duration),
+                new SortOrder<Assignment>(x => x.Date))
+                .ToArray<Assignment>();
+        });
 
-            public InvoiceLineDetail()
-            {
-                TaxCode = new TaxCodeLink();
-            }
-        }
-        
-        public static void GenerateInvoiceLines(Guid invoiceid, InvoiceTimeCalculation timesummary, InvoiceMaterialCalculation partsummary, IProgress<String>? progress )
+        var activities = await activitiesTask;
+        foreach (var assignment in await assignmentsTask)
         {
-            
-            CustomerActivitySummary[] activities = new CustomerActivitySummary[] { };
-            CustomerProductSummary[] products = new CustomerProductSummary[] { };
+            var id = timesummary switch
+            {
+                InvoiceTimeCalculation.Detailed => assignment.ID,
+                InvoiceTimeCalculation.Activity => assignment.ActivityLink.ID,
+                _ => Guid.Empty
+            };
 
-            Assignment[] assignments = new Assignment[] { };
-            var movements = Array.Empty<StockMovement>();
+            var description = timesummary switch
+            {
+                InvoiceTimeCalculation.Detailed => string.Format("{0:dd MMM yy} - {1}", assignment.Date, assignment.Description),
+                InvoiceTimeCalculation.Activity => assignment.ActivityLink.Description,
+                _ => "Labour"
+            };
 
-            progress?.Report("Loading Invoice");
-            var invoice = new Client<Invoice>().Load(Filter<Invoice>.Where(x => x.ID).IsEqualTo(invoiceid)).FirstOrDefault();
-            
-            progress?.Report("Loading Detail Data");
-            var setup = new Task[]
+            var quantity = assignment.Charge.OverrideQuantity
+                ? TimeSpan.FromHours(assignment.Charge.Quantity)
+                : assignment.Actual.Duration;
+
+            var activity = activities.GetValueOrDefault((invoice.CustomerLink.ID, assignment.ActivityLink.ID))?.FirstOrDefault()
+                ?? activities.GetValueOrDefault((Guid.Empty, assignment.ActivityLink.ID))?.FirstOrDefault()
+                ?? new CustomerActivitySummary();
+
+            double charge;
+            if (assignment.Charge.OverrideCharge)
+            {
+                charge = quantity.TotalHours * assignment.Charge.Charge;
+            }
+            else
             {
+                var fixedcharge = activity.Charge.FixedCharge;
                 
+                var chargeperiod = !activity.Charge.ChargePeriod.Equals(TimeSpan.Zero)
+                    ? activity.Charge.ChargePeriod
+                    : TimeSpan.FromHours(1);
                 
-                Task.Run(() =>
-                {
-                    var oldlines = new Client<InvoiceLine>().Query(
-                        Filter<InvoiceLine>.Where(x => x.InvoiceLink.ID).IsEqualTo(invoice.ID),
-                        Columns.None<InvoiceLine>().Add(x => x.ID)
-                    ).Rows.Select(x => x.ToObject<InvoiceLine>()).ToArray();
-                    new Client<InvoiceLine>().Delete(oldlines, "");
-                }),
-
-                Task.Run(() =>
-                {
-                    activities =
-                        new Client<CustomerActivitySummary>().Query(
-                            Filter<CustomerActivitySummary>.Where(x => x.Customer.ID).InList(invoice.CustomerLink.ID, Guid.Empty),
-                            Columns.None<CustomerActivitySummary>().Add(x => x.Customer.ID)
-                                .Add(x => x.Activity.ID)
-                                .Add(x => x.Activity.Code)
-                                .Add(x => x.Activity.Description)
-                                .Add(x => x.Charge.TaxCode.ID)
-                                .Add(x => x.Charge.TaxCode.Rate)
-                                .Add(x => x.Charge.Chargeable)
-                                .Add(x => x.Charge.FixedCharge)
-                                .Add(x => x.Charge.ChargeRate)
-                                .Add(x => x.Charge.ChargePeriod)
-                                .Add(x => x.Charge.MinimumCharge)
-                        ).Rows.Select(r => r.ToObject<CustomerActivitySummary>()).ToArray();
-                }),
-
-                Task.Run(() =>
-                {
-                    assignments = new Client<Assignment>().Query(
-                        Filter<Assignment>.Where(x => x.Invoice.ID).IsEqualTo(invoice.ID).And(x => x.Charge.Chargeable).IsEqualTo(true),
-                        null,
-                        new SortOrder<Assignment>(x => x.Date)
-                    ).Rows.Select(x => x.ToObject<Assignment>()).ToArray();
-                }),
-
-                Task.Run(() =>
-                {
-                    products =
-                        new Client<CustomerProductSummary>().Query(
-                            Filter<CustomerProductSummary>.Where(x => x.Customer.ID).InList(invoice.CustomerLink.ID, Guid.Empty),
-                            Columns.None<CustomerProductSummary>().Add(x => x.Customer.ID)
-                                .Add(x => x.Product.ID)
-                                .Add(x => x.Product.Code)
-                                .Add(x => x.Product.Name)
-                                .Add(x => x.Product.TaxCode.ID)
-                                .Add(x => x.Product.TaxCode.Rate)
-                                .Add(x => x.Charge.Chargeable)
-                                .Add(x => x.Charge.PriceType)
-                                .Add(x => x.Charge.Price)
-                                .Add(x => x.Charge.Markup)
-                        ).Rows.Select(r => r.ToObject<CustomerProductSummary>()).ToArray();
-                }),
-
-                Task.Run(() =>
+                var rounded = quantity.Ceiling(chargeperiod);
+
+                var multiplier = TimeSpan.FromHours(1).TotalHours / chargeperiod.TotalHours;
+                var rate = activity.Charge.ChargeRate * multiplier;
+                
+                var mincharge = activity.Charge.MinimumCharge;
+
+                charge = Math.Max(fixedcharge + (rounded.TotalHours * rate), mincharge);
+            }
+
+            if(!timelines.TryGetValue(id, out var timeline))
+            {
+                timeline = new InvoiceLineDetail
                 {
-                    movements = new Client<StockMovement>().Query(
-                        Filter<StockMovement>.Where(x => x.Invoice.ID).IsEqualTo(invoice.ID).And(x => x.Charge.Chargeable).IsEqualTo(true),
-                        null
-                    ).ToArray<StockMovement>();
-                })
+                    Description = description
+                };
+                timeline.TaxCode.CopyFrom(activity.Charge.TaxCode);
+                timelines.Add(id, timeline);
+            }
+
+            timeline.Quantity += quantity.TotalHours;
+            timeline.Charge += charge;
+        }
+
+        return timelines.Values.ToArray(line =>
+        {
+            var update = new InvoiceLine();
+            update.InvoiceLink.ID = invoice.ID;
+            update.Description = line.Description;
+            update.TaxCode.CopyFrom(line.TaxCode);
+            update.Quantity = timesummary != InvoiceTimeCalculation.Collapsed ? line.Quantity : 1;
+            update.ExTax = line.Charge;
+            return update;
+        });
+    }
+    private static async Task<InvoiceLine[]> PartLines(Invoice invoice, InvoiceMaterialCalculation partsummary)
+    {
+        var productsTask = Task.Run(() =>
+        {
+            return Client.Query(
+                Filter<CustomerProductSummary>.Where(x => x.Customer.ID).InList(invoice.CustomerLink.ID, Guid.Empty),
+                Columns.None<CustomerProductSummary>()
+                    .Add(x => x.Customer.ID)
+                    .Add(x => x.Product.ID)
+                    .Add(x => x.Product.Code)
+                    .Add(x => x.Product.Name)
+                    .Add(x => x.Product.TaxCode.ID)
+                    .Add(x => x.Product.TaxCode.Rate)
+                    .Add(x => x.Charge.Chargeable)
+                    .Add(x => x.Charge.PriceType)
+                    .Add(x => x.Charge.Price)
+                    .Add(x => x.Charge.Markup))
+                .ToObjects<CustomerProductSummary>()
+                .GroupByDictionary(x => (CustomerID: x.Customer.ID, ProductID: x.Product.ID));
+        });
+        var movementsTask = Task.Run(() =>
+        {
+            return Client.Query(
+                Filter<StockMovement>.Where(x => x.Invoice.ID).IsEqualTo(invoice.ID).And(x => x.Charge.Chargeable).IsEqualTo(true),
+                Columns.None<StockMovement>()
+                    .Add(x => x.ID)
+                    .Add(x => x.Qty)
+                    .Add(x => x.Product.ID)
+                    .Add(x => x.Product.Name)
+                    .Add(x => x.Product.CostCentre.ID)
+                    .Add(x => x.Product.CostCentre.Description)
+                    .Add(x => x.Style.Code)
+                    .Add(x => x.Dimensions.UnitSize)
+                    .Add(x => x.Charge.OverrideCharge)
+                    .Add(x => x.Charge.Charge)
+                    .Add(x => x.Charge.OverrideQuantity)
+                    .Add(x => x.Charge.Quantity))
+                .ToArray<StockMovement>();
+        });
+        
+        var partlines = new Dictionary<Guid, InvoiceLineDetail>();
+
+        var products = await productsTask;
+        foreach (var item in await movementsTask)
+        {
+            var id = partsummary switch
+            {
+                InvoiceMaterialCalculation.Detailed => item.ID,
+                InvoiceMaterialCalculation.Product => item.Product.ID,
+                InvoiceMaterialCalculation.CostCentre => item.Product.CostCentre.ID,
+                _ => Guid.Empty
+            };
+
+            var description = partsummary switch
+            {
+                InvoiceMaterialCalculation.Detailed => $"{item.Product.Name}: {item.Style.Code}; {item.Dimensions.UnitSize}",
+                InvoiceMaterialCalculation.Product => item.Product.Name,
+                InvoiceMaterialCalculation.CostCentre => item.Product.CostCentre.Description,
+                _ => "Materials"
             };
             
-            Task.WaitAll(setup);
+            var quantity = item.Charge.OverrideQuantity
+                ? item.Charge.Quantity
+                : item.Qty;
             
-            List<InvoiceLine> updates = new List<InvoiceLine>();
-
-            progress?.Report("Calculating...");
-            var timelines = new List<InvoiceLineDetail>();
+            var product = 
+                products.GetValueOrDefault((invoice.CustomerLink.ID, item.Product.ID))?.FirstOrDefault()
+                ?? products.GetValueOrDefault((Guid.Empty, item.Product.ID))?.FirstOrDefault()
+                ?? new CustomerProductSummary();
 
-            foreach (var assignment in assignments)
+            double charge;
+            if (item.Charge.OverrideCharge)
             {
-                
-                var id = timesummary switch
+                charge = quantity * item.Charge.Charge;
+            }
+            else
+            {
+                charge = quantity * (product.Charge.PriceType switch
                 {
-                    InvoiceTimeCalculation.Detailed => assignment.ID,
-                    InvoiceTimeCalculation.Activity => assignment.ActivityLink.ID,
-                    _ => Guid.Empty
-                };
+                    ProductPriceType.CostPlus => 1 + product.Charge.Markup / 100,
+                    _ => product.Charge.Price
+                });
+            }
 
-                var description = timesummary switch
+            if(!partlines.TryGetValue(id, out var partline))
+            {
+                partline = new InvoiceLineDetail
                 {
-                    InvoiceTimeCalculation.Detailed => string.Format("{0:dd MMM yy} - {1}", assignment.Date, assignment.Description),
-                    InvoiceTimeCalculation.Activity => assignment.ActivityLink.Description,
-                    _ => "Labour"
+                    Description = description
                 };
-                
-                var quantity = assignment.Charge.OverrideQuantity
-                    ? TimeSpan.FromHours(assignment.Charge.Quantity)
-                    : assignment.Actual.Duration;
-                
-                var activity = 
-                    activities.FirstOrDefault(x => x.Customer.ID.Equals(invoice.CustomerLink.ID) && x.Activity.ID.Equals(assignment.ActivityLink.ID))
-                    ?? activities.FirstOrDefault(x => x.Customer.ID.Equals(Guid.Empty) && x.Activity.ID.Equals(assignment.ActivityLink.ID))
-                    ?? new CustomerActivitySummary();
-                
-                double charge = 0.0F;
-                if (assignment.Charge.OverrideCharge)
-                    charge = quantity.TotalHours * assignment.Charge.Charge;
-                else
-                {
-                    
-                    double fixedcharge = activity.Charge.FixedCharge;
-                    
-                    TimeSpan chargeperiod = !activity.Charge.ChargePeriod.Equals(TimeSpan.Zero)
-                        ? activity.Charge.ChargePeriod
-                        : TimeSpan.FromHours(1);
-                    
-                    var rounded = quantity.Ceiling(chargeperiod);
-
-                    double multiplier = TimeSpan.FromHours(1).TotalHours / chargeperiod.TotalHours;
-                    double rate = activity.Charge.ChargeRate * multiplier;
-                    
-                    double mincharge = activity.Charge.MinimumCharge;
-
-                    charge = Math.Max(fixedcharge + (rounded.TotalHours * rate), mincharge);
-                }
-
-                var timeline = timelines.FirstOrDefault(x => x.ID == id);
-                if (timeline == null)
-                {
-                    timeline = new InvoiceLineDetail();
-                    timeline.Description = description;
-                    timeline.TaxCode.ID = activity.Charge.TaxCode.ID;
-                    timeline.TaxCode.Synchronise(activity.Charge.TaxCode);
-                    timelines.Add(timeline);
-                }
+                partline.TaxCode.CopyFrom(product.Product.TaxCode);
+                partlines.Add(id, partline);
+            }
 
-                timeline.Quantity += quantity.TotalHours;
-                timeline.Charge += charge;
+            partline.Quantity += quantity;
+            partline.Charge += charge;               
+        }
 
-            }
-            
-            foreach (var line in timelines)
+        return partlines.Values.ToArray(line =>
+        {
+            var update = new InvoiceLine();
+            update.InvoiceLink.ID = invoice.ID;
+            update.Description = line.Description;
+            update.TaxCode.CopyFrom(line.TaxCode);
+            update.Quantity = new[] { InvoiceMaterialCalculation.Detailed, InvoiceMaterialCalculation.Product }.Contains(partsummary) ? line.Quantity : 1.0F;
+            update.ExTax = line.Charge;
+            return update;
+        });
+    }
+    private static async Task<InvoiceLine[]> ExpenseLines(Invoice invoice, InvoiceExpensesCalculation expensesSummary)
+    {
+        var billLinesTask = Task.Run(() =>
+        {
+            return Client.Query(
+                Filter<BillLine>.Where(x => x.Invoice.ID).IsEqualTo(invoice.ID).And(x => x.Charge.Chargeable).IsEqualTo(true),
+                Columns.None<BillLine>()
+                    .Add(x => x.ID)
+                    .Add(x => x.Description)
+                    .Add(x => x.ExTax)
+                    .Add(x => x.TaxCode.ID)
+                    .Add(x => x.TaxCode.Rate)
+                    .Add(x => x.Charge.OverrideCharge)
+                    .Add(x => x.Charge.Charge)
+                    .Add(x => x.Charge.OverrideQuantity)
+                    .Add(x => x.Charge.Quantity))
+                .ToArray<BillLine>();
+        });
+        
+        var expenselines = new Dictionary<Guid, InvoiceLineDetail>();
+
+        foreach (var item in await billLinesTask)
+        {
+            var id = expensesSummary switch
             {
-                var update = new InvoiceLine();
-                update.InvoiceLink.ID = invoice.ID;
-                update.Description = line.Description;
-                update.TaxCode.ID = line.TaxCode.ID;
-                update.TaxCode.Synchronise(line.TaxCode);
-                update.Quantity = timesummary != InvoiceTimeCalculation.Collapsed ? line.Quantity : 1;
-                update.ExTax = line.Charge;
-                updates.Add(update);
-            }
+                InvoiceExpensesCalculation.Detailed => item.ID,
+                _ => Guid.Empty
+            };
+
+            var description = expensesSummary switch
+            {
+                InvoiceExpensesCalculation.Detailed => $"{item.Description}",
+                _ => "Expenses"
+            };
             
-            var partlines = new List<InvoiceLineDetail>();
+            var quantity = item.Charge.OverrideQuantity
+                ? item.Charge.Quantity
+                : 1.0;
             
-            foreach (var item in movements)
+            double charge;
+            if (item.Charge.OverrideCharge)
             {
+                charge = quantity * item.Charge.Charge;
+            }
+            else
+            {
+                charge = quantity * item.ExTax * (1 + invoice.CustomerLink.Markup / 100);
+            }
 
-                var id = partsummary switch
-                {
-                    InvoiceMaterialCalculation.Detailed => item.ID,
-                    InvoiceMaterialCalculation.Product => item.Product.ID,
-                    InvoiceMaterialCalculation.CostCentre => item.Product.CostCentre.ID,
-                    _ => Guid.Empty
-                };
-
-                var description = partsummary switch
+            if(!expenselines.TryGetValue(id, out var expenseLine))
+            {
+                expenseLine = new InvoiceLineDetail
                 {
-                    InvoiceMaterialCalculation.Detailed => $"{item.Product.Name}: {item.Style.Code}; {item.Dimensions.UnitSize}",
-                    InvoiceMaterialCalculation.Product => item.Product.Name,
-                    InvoiceMaterialCalculation.CostCentre => item.Product.CostCentre.Description,
-                    _ => "Materials"
+                    Description = description
                 };
-                
-                var quantity = item.Charge.OverrideQuantity
-                    ? item.Charge.Quantity
-                    : item.Qty;
-                
-                var product = 
-                    products.FirstOrDefault(x => x.Customer.ID.Equals(invoice.CustomerLink.ID) && x.Product.ID.Equals(item.Product.ID))
-                    ?? products.FirstOrDefault(x => x.Customer.ID.Equals(Guid.Empty) && x.Product.ID.Equals(item.Product.ID))
-                    ?? new CustomerProductSummary();
-                
-                double charge = 0.0F;
-                if (item.Charge.OverrideCharge)
-                    charge = quantity * item.Charge.Charge;
-                else
-                {
-                    charge = quantity * product.Charge.PriceType switch
-                    {
-                        ProductPriceType.CostPlus => 0.0F * product.Charge.Markup,
-                        _ => product.Charge.Price
-                    };
-                    
-                }
-
-                var partline = partlines.FirstOrDefault(x => x.ID == id);
-                if (partline == null)
-                {
-                    partline = new InvoiceLineDetail();
-                    partline.ID = id;
-                    partline.Description = description;
-                    partline.TaxCode.ID = product.Product.TaxCode.ID;
-                    partline.TaxCode.Synchronise(product.Product.TaxCode);
-                    partlines.Add(partline);
-                }
-
-                partline.Quantity += quantity;
-                partline.Charge += charge;               
-                
+                expenseLine.TaxCode.CopyFrom(item.TaxCode);
+                expenselines.Add(id, expenseLine);
             }
 
-            foreach (var line in partlines)
-            {
-                var update = new InvoiceLine();
-                update.InvoiceLink.ID = invoice.ID;
-                update.Description = line.Description;
-                update.TaxCode.ID = line.TaxCode.ID;
-                update.TaxCode.Synchronise(line.TaxCode);
-                update.Quantity = new[] { InvoiceMaterialCalculation.Detailed, InvoiceMaterialCalculation.Product}.Contains(partsummary) ? line.Quantity : 1.0F;
-                update.ExTax = line.Charge;
-                updates.Add(update);               
-            }
+            expenseLine.Quantity += quantity;
+            expenseLine.Charge += charge;               
+        }
 
-            progress?.Report("Creating Invoice Lines");
-            if (updates.Any())
-                new Client<InvoiceLine>().Save(updates, "Recalculating Invoice from Time and Materials");
-            
+        return expenselines.Values.ToArray(line =>
+        {
+            var update = new InvoiceLine();
+            update.InvoiceLink.ID = invoice.ID;
+            update.Description = line.Description;
+            update.TaxCode.CopyFrom(line.TaxCode);
+            update.Quantity = expensesSummary != InvoiceExpensesCalculation.Collapsed ? line.Quantity : 1.0F;
+            update.ExTax = line.Charge;
+            return update;
+        });
+    }
+    
+    public static void GenerateInvoiceLines(
+        Guid invoiceid,
+        InvoiceTimeCalculation timesummary,
+        InvoiceMaterialCalculation partsummary,
+        InvoiceExpensesCalculation expensesSummary,
+        IProgress<String>? progress
+    )
+    {
+        progress?.Report("Loading Invoice");
+        var invoice = Client.Query(
+            Filter<Invoice>.Where(x => x.ID).IsEqualTo(invoiceid))
+            .ToObjects<Invoice>().FirstOrDefault();
+        if(invoice is null)
+        {
+            Logger.Send(LogType.Error, "", $"Could not find invoice with ID {invoiceid}");
+            return;
         }
+        
+        progress?.Report("Loading Detail Data");
+
+        var deleteOldTask = Task.Run(() =>
+        {
+            var oldlines = new Client<InvoiceLine>().Query(
+                Filter<InvoiceLine>.Where(x => x.InvoiceLink.ID).IsEqualTo(invoice.ID),
+                Columns.None<InvoiceLine>().Add(x => x.ID)
+            ).Rows.Select(x => x.ToObject<InvoiceLine>()).ToArray();
+            new Client<InvoiceLine>().Delete(oldlines, "");
+        });
+
+        var timeLinesTask = TimeLines(invoice, timesummary);
+        var partLinesTask = PartLines(invoice, partsummary);
+        var expenseLinesTask = ExpenseLines(invoice, expensesSummary);
 
+        progress?.Report("Calculating...");
+
+        var updates = CoreUtils.Concatenate(
+            timeLinesTask.Result,
+            partLinesTask.Result,
+            expenseLinesTask.Result);
+
+        progress?.Report("Creating Invoice Lines");
+        Client.Save(updates, "Recalculating Invoice from Time and Materials");
     }
+
 }

+ 5 - 3
prs.stores/BillLineStore.cs

@@ -16,11 +16,13 @@ namespace Comal.Stores
                 Filter<PurchaseOrderItem> filter = (entity.OrderItem.ID != Guid.Empty)
                     ? Filter<PurchaseOrderItem>.Where(x => x.ID).IsEqualTo(entity.OrderItem.ID)
                     : Filter<PurchaseOrderItem>.Where(x => x.ID).IsEqualTo(entity.OrderItem.GetOriginalValue(x => x.ID));
-                
+
                 var items = Provider.Query(
                     filter,
-                    Columns.Required<PurchaseOrderItem>().Add(x => x.ID).Add(x=>x.BillLine.ID)
-                ).Rows.Select(x=>x.ToObject<PurchaseOrderItem>()).ToArray();
+                    Columns.Required<PurchaseOrderItem>()
+                        .Add(x => x.ID)
+                        .Add(x => x.BillLine.ID))
+                    .ToArray<PurchaseOrderItem>();
 
                 foreach (var item in items)
                     item.BillLine.ID = entity.OrderItem.ID != Guid.Empty ? entity.ID : Guid.Empty;