diff --git a/.aspire/settings.json b/.aspire/settings.json
new file mode 100644
index 000000000..deede96df
--- /dev/null
+++ b/.aspire/settings.json
@@ -0,0 +1,3 @@
+{
+ "appHostPath": "../src/src.AppHost/src.AppHost.csproj"
+}
\ No newline at end of file
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
deleted file mode 100644
index 45434e736..000000000
--- a/.devcontainer/devcontainer.json
+++ /dev/null
@@ -1,38 +0,0 @@
-// For format details, see https://aka.ms/devcontainer.json. For config options, see the
-// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet
-{
- "name": "AccountGo (.NET)",
- // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
- "image": "mcr.microsoft.com/devcontainers/dotnet:0-7.0-bullseye",
- "features": {
- "ghcr.io/devcontainers/features/azure-cli:1": {},
- "ghcr.io/devcontainers/features/git:1": {},
- "ghcr.io/dhoeric/features/google-cloud-cli:1": {},
- "ghcr.io/warrenbuckley/codespace-features/sqlite:1": {},
- "ghcr.io/devcontainers/features/docker-in-docker:1": {
- "version": "latest",
- "moby": true
- },
- "ghcr.io/devcontainers/features/node:1": {}
- }
-
- // Features to add to the dev container. More info: https://containers.dev/features.
- // "features": {},
-
- // Use 'forwardPorts' to make a list of ports inside the container available locally.
- // "forwardPorts": [5000, 5001],
- // "portsAttributes": {
- // "5001": {
- // "protocol": "https"
- // }
- // }
-
- // Use 'postCreateCommand' to run commands after the container is created.
- // "postCreateCommand": "dotnet restore",
-
- // Configure tool-specific properties.
- // "customizations": {},
-
- // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
- // "remoteUser": "root"
-}
diff --git a/.github/workflows/build-deploy-azure.yml b/.github/workflows/build-deploy-azure.yml
deleted file mode 100644
index eb332b2cd..000000000
--- a/.github/workflows/build-deploy-azure.yml
+++ /dev/null
@@ -1,19 +0,0 @@
-name: Docker Image CI
-
-on:
- push:
- branches: [ "main" ]
- pull_request:
- branches: [ "main" ]
- workflow_dispatch:
-
-jobs:
-
- build:
-
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/checkout@v3
- - name: Build the Docker image
- run: docker-compose build
diff --git a/.github/workflows/gdbapi.yml b/.github/workflows/gdbapi.yml
new file mode 100644
index 000000000..f0071cee4
--- /dev/null
+++ b/.github/workflows/gdbapi.yml
@@ -0,0 +1,94 @@
+# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
+# More GitHub Actions for Azure: https://github.com/Azure/actions
+
+name: Build and deploy Good Deed Books API to Azure Web App - gdbapi
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up .NET Core
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.x'
+
+ - name: Install dotnet-ef tool
+ run: |
+ dotnet tool install --global dotnet-ef
+ echo "++++ dotnet-ef version"
+ dotnet ef --version
+
+ - name: Install dotnet aspire workload
+ run: |
+ dotnet workload install aspire
+
+ - name: Build with dotnet
+ run: |
+ echo "++++ dotnet build"
+ dotnet build --configuration Release
+
+ - name: Run unit tests
+ run: dotnet test ./test/GoodBooks.BackendTests/GoodBooks.BackendTests.csproj --configuration Release
+
+ - name: Add migrations
+ run: |
+ echo "++++ current directory"
+ pwd
+ echo "++++ add ApplicationIdentityDbContext migration IdentityMig"
+ dotnet ef migrations add IdentityMig --project ./src/Api/ --startup-project ./src/Api/Api.csproj --msbuildprojectextensionspath .build/obj/Api/ --context ApplicationIdentityDbContext --output-dir Data/Migrations/IdentityDb
+ echo "++++ add ApiDbContext migration ApiMig"
+ dotnet ef migrations add ApiMig --project ./src/Api/ --startup-project ./src/Api/Api.csproj --msbuildprojectextensionspath .build/obj/Api/ --context ApiDbContext --output-dir Data/Migrations/ApiDb
+ echo "++++ contents of ./src/Api/Data/Migrations/IdentityDb"
+ ls ./src/Api/Data/Migrations/IdentityDb
+ echo "++++ contents of ./src/Api/Data/Migrations/ApiDb"
+ ls ./src/Api/Data/Migrations/ApiDb
+
+ - name: dotnet publish
+ run: |
+ echo "++++ contents of dotnet publish ./src/Api/Api.csproj"
+ dotnet publish ./src/Api/Api.csproj -f net10.0 -c Release -o "${{runner.temp}}/myapp"
+
+ - name: Upload artifact for deployment job
+ uses: actions/upload-artifact@v4
+ with:
+ name: .net-app
+ path: ${{runner.temp}}/myapp
+
+ deploy:
+ runs-on: windows-latest
+ needs: build
+ environment:
+ name: 'Production'
+ url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
+ permissions:
+ id-token: write #This is required for requesting the JWT
+
+ steps:
+ - name: Download artifact from build job
+ uses: actions/download-artifact@v4
+ with:
+ name: .net-app
+
+ - name: Login to Azure
+ uses: azure/login@v2
+ with:
+ client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_A17E281C175C4E629A76134AA823BAC5 }}
+ tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_258CF23452C24D9795BD94B25EF50B73 }}
+ subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_9375B274C69740D39F4770D5D433E8B1 }}
+
+ - name: Deploy to Azure Web App
+ id: deploy-to-webapp
+ uses: azure/webapps-deploy@v3
+ with:
+ app-name: 'gdbapi'
+ slot-name: 'Production'
+ package: .
diff --git a/.github/workflows/gdbmvc_tar.yml b/.github/workflows/gdbmvc_tar.yml
new file mode 100644
index 000000000..9589e40f3
--- /dev/null
+++ b/.github/workflows/gdbmvc_tar.yml
@@ -0,0 +1,99 @@
+# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
+# More GitHub Actions for Azure: https://github.com/Azure/actions
+name: Build and deploy Good Deed Books MVC project to Azure
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up .NET Core
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.x'
+ include-prerelease: true
+
+ - name: Install dotnet aspire workload
+ run: |
+ dotnet workload install aspire
+
+ - name: Build with dotnet
+ run: dotnet build --configuration Release
+
+ - name: Run unit tests
+ run: dotnet test ./test/GoodBooks.BackendTests/GoodBooks.BackendTests.csproj --configuration Release
+
+ - name: dotnet publish
+ run: dotnet publish ./src/AccountGoWeb/AccountGoWeb.csproj -c Release -o ${{runner.temp}}/myapp
+
+ - name: Archive production artifacts
+ run: |
+ echo "+++++++++++++++++++++++++ where am I? ++++++++++++++++++++++++"
+ pwd
+ echo "+++++++++++++++++++++++++ save current directory into a variable dir ++++"
+ dir=$(pwd)
+ echo "+++++++++ what is in variable dir ++++++++++++++"
+ echo $dir
+ echo "++++++++++++++++++++++++ what's in current directory? ++++++++"
+ ls -al
+ echo "+++++ what's in the ${{runner.temp}}/myapp directory? ++++"
+ ls -al ${{runner.temp}}/myapp
+ echo "+++++ change directory to ${{runner.temp}}/myapp ++++"
+ cd ${{runner.temp}}/myapp
+ echo "+++++++++++++++++++++++++ where am I? ++++++++++++++++++++++++"
+ pwd
+ echo "+++++++++++++++++++++++++ compress current directory and save in $dir/my_artifact.tar.gz ++++"
+ tar -czvf $dir/my_artifact.tar.gz .
+ echo "+++++++++++++++++++++++++ change back to $dir directory ++++"
+ cd $dir
+ echo "++++++++++++++++++++++++ what's in $dir directory? ++++++++"
+ ls -al
+
+ - name: Upload artifact for deployment job
+ uses: actions/upload-artifact@v4
+ with:
+ name: .net-app
+ path: my_artifact.tar.gz
+
+ deploy:
+ runs-on: ubuntu-latest
+ needs: build
+ environment:
+ name: 'Production'
+ url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
+ permissions:
+ id-token: write #This is required for requesting the JWT
+
+ steps:
+ - name: Download artifact from build job
+ uses: actions/download-artifact@v4
+ with:
+ name: .net-app
+
+ - name: Login to Azure
+ uses: azure/login@v2
+ with:
+ client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_8B6389BB3F37413FB2483AC2574C3BCB }}
+ tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_FD62C59DE5DC42C2A07DB8191A522348 }}
+ subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_7076EF307FDA4C11BC99A0A7A0943794 }}
+
+ - name: Extract artifacts
+ run: |
+ tar -xzvf my_artifact.tar.gz -C .
+
+ - name: Set startup command
+ run: |
+ az webapp config set --resource-group goodbooks-RG --name gdbmvc --startup-file "dotnet /home/site/wwwroot/GoodBooks.dll"
+
+ - name: Deploy to Azure Web App
+ id: deploy-to-webapp
+ uses: azure/webapps-deploy@v3
+ with:
+ app-name: 'gdbmvc'
+ slot-name: 'Production'
+ package: .
diff --git a/.gitignore b/.gitignore
index fbb3738a8..6272404dc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,9 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
+# custom 2024/04/29
+.idea
+
# User-specific files
*.suo
*.user
@@ -18,6 +21,7 @@ build/
bld/
[Bb]in/
[Oo]bj/
+node_modules/
# Roslyn cache directories
*.ide/
@@ -186,6 +190,10 @@ FakesAssemblies/
# Lib folder generated by gulpfile.js
**/src/[Ww]eb[Aa]ngular/wwwroot/[Ll]ib/*
+
+
+**/src/[Rr]eact[Ff]ront[Ee]nd/wwwroot/*
+
**/src/[Ww]eb[Aa]pp/wwwroot/app/scripts/*
**/src/[Ww]eb[Aa]pp/wwwroot/app/compiledscripts/*
**/src/[Ww]eb[Aa]pp/wwwroot/app/typescripts/compiledscripts/*
@@ -215,7 +223,8 @@ FakesAssemblies/
/src/Api/Plugins/*
/src/AccountGoWeb/Modules/*
/src/AccountGoWeb/Plugins/*
-/src/Api/Data/Migrations
+# /src/Api/Data/Migrations
.vscode
-exclude
\ No newline at end of file
+exclude
+/src/Api/appsettings.Development.json
diff --git a/Directory.Build.props b/Directory.Build.props
index 208007c11..af7722ad7 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -13,9 +13,10 @@
git
embedded
false
- $(NoWarn);NU5105
+ $(NoWarn);NU5105;NU1901;NU1903
false
+ false
true
$(MSBuildThisFileDirectory).build\obj\$(MSBuildProjectName)\
$(MSBuildThisFileDirectory).build\bin\$(MSBuildProjectName)\
diff --git a/accountgo.sln b/accountgo.sln
index 4eaa3f47d..29ef7bb4d 100644
--- a/accountgo.sln
+++ b/accountgo.sln
@@ -1,7 +1,7 @@
Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio 15
-VisualStudioVersion = 15.0.26228.4
+# Visual Studio Version 17
+VisualStudioVersion = 17.8.34322.80
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Presentation", "Presentation", "{0295DFAC-BF6E-46C0-A63D-FBE9AF3C04E5}"
EndProject
@@ -19,62 +19,234 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AccountGoWeb", "src\Account
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dto", "src\Dto\Dto.csproj", "{1E610F55-2D74-4856-818B-0D0B47601B75}"
EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{E1B45442-3F2D-491A-9D8A-0DDA50309A1A}"
-EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure", "src\Infrastructure\Infrastructure.csproj", "{EBFAFB5B-494F-48D5-A70D-AF1490B9260A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0EAC5155-A5EA-49C1-8E0C-19DD36D2C21C}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Module.Tests", "test\Module.Tests\Module.Tests.csproj", "{54631590-2A41-45F4-B057-92C840ED08C1}"
EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{E0861852-0F5B-4810-8586-A59038BC4034}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GoodBooks.BackendTests", "test\GoodBooks.BackendTests\GoodBooks.BackendTests.csproj", "{C59F300E-4BAC-4329-9A41-8F1D75A7E197}"
+EndProject
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{EF0BD6F1-00D6-41E5-91AB-8B606D35D448}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorGDB", "src\BlazorGDB\BlazorGDB\BlazorGDB.csproj", "{AB5F238F-AB78-4A85-8D8D-17E211015FD3}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorGDB.Client", "src\BlazorGDB\BlazorGDB.Client\BlazorGDB.Client.csproj", "{12BE663C-C0DD-4343-93DF-6B2D853B6B79}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibraryGDB", "src\LibraryGDB\LibraryGDB.csproj", "{F64790E0-86AD-4562-9AC5-F4DD3F4881BA}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Aspire", "Aspire", "{64880D93-BAB4-FF83-898C-B934B68C31A9}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "src.ServiceDefaults", "src\src.ServiceDefaults\src.ServiceDefaults.csproj", "{949C95E9-4261-416E-8D2A-F05E3D4640CA}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "src.AppHost", "src\src.AppHost\src.AppHost.csproj", "{ADDBCE30-FE7F-4198-8A37-772EF6BF3676}"
EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SampleModule", "src\Modules\SampleModule\SampleModule.csproj", "{B296277A-C822-444E-8CFA-4CC4C1C1F737}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MigrationService", "src\MigrationService\MigrationService.csproj", "{DF084D96-707B-47C2-9493-85FA84631ACE}"
EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SampleNetStandard20", "test\SampleModules\SampleNetStandard20\SampleNetStandard20.csproj", "{B0AB6EA7-7D53-4457-9482-F0613F99E3BB}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GoodBooks.ServicesTests", "test\GoodBooks.ServicesTests\GoodBooks.ServicesTests.csproj", "{1764E3C7-CAAF-4FE9-9104-CF26D8121FC9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{9B652491-4E9C-45E0-BE5B-EA6AF892F380}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9B652491-4E9C-45E0-BE5B-EA6AF892F380}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9B652491-4E9C-45E0-BE5B-EA6AF892F380}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {9B652491-4E9C-45E0-BE5B-EA6AF892F380}.Debug|x64.Build.0 = Debug|Any CPU
+ {9B652491-4E9C-45E0-BE5B-EA6AF892F380}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {9B652491-4E9C-45E0-BE5B-EA6AF892F380}.Debug|x86.Build.0 = Debug|Any CPU
{9B652491-4E9C-45E0-BE5B-EA6AF892F380}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9B652491-4E9C-45E0-BE5B-EA6AF892F380}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9B652491-4E9C-45E0-BE5B-EA6AF892F380}.Release|x64.ActiveCfg = Release|Any CPU
+ {9B652491-4E9C-45E0-BE5B-EA6AF892F380}.Release|x64.Build.0 = Release|Any CPU
+ {9B652491-4E9C-45E0-BE5B-EA6AF892F380}.Release|x86.ActiveCfg = Release|Any CPU
+ {9B652491-4E9C-45E0-BE5B-EA6AF892F380}.Release|x86.Build.0 = Release|Any CPU
{C02DECC9-2A82-42C0-8F26-D0AE6559AC5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C02DECC9-2A82-42C0-8F26-D0AE6559AC5E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C02DECC9-2A82-42C0-8F26-D0AE6559AC5E}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C02DECC9-2A82-42C0-8F26-D0AE6559AC5E}.Debug|x64.Build.0 = Debug|Any CPU
+ {C02DECC9-2A82-42C0-8F26-D0AE6559AC5E}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C02DECC9-2A82-42C0-8F26-D0AE6559AC5E}.Debug|x86.Build.0 = Debug|Any CPU
{C02DECC9-2A82-42C0-8F26-D0AE6559AC5E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C02DECC9-2A82-42C0-8F26-D0AE6559AC5E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C02DECC9-2A82-42C0-8F26-D0AE6559AC5E}.Release|x64.ActiveCfg = Release|Any CPU
+ {C02DECC9-2A82-42C0-8F26-D0AE6559AC5E}.Release|x64.Build.0 = Release|Any CPU
+ {C02DECC9-2A82-42C0-8F26-D0AE6559AC5E}.Release|x86.ActiveCfg = Release|Any CPU
+ {C02DECC9-2A82-42C0-8F26-D0AE6559AC5E}.Release|x86.Build.0 = Release|Any CPU
{09096FEC-DA29-4914-B046-CD280220C52A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{09096FEC-DA29-4914-B046-CD280220C52A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {09096FEC-DA29-4914-B046-CD280220C52A}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {09096FEC-DA29-4914-B046-CD280220C52A}.Debug|x64.Build.0 = Debug|Any CPU
+ {09096FEC-DA29-4914-B046-CD280220C52A}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {09096FEC-DA29-4914-B046-CD280220C52A}.Debug|x86.Build.0 = Debug|Any CPU
{09096FEC-DA29-4914-B046-CD280220C52A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{09096FEC-DA29-4914-B046-CD280220C52A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {09096FEC-DA29-4914-B046-CD280220C52A}.Release|x64.ActiveCfg = Release|Any CPU
+ {09096FEC-DA29-4914-B046-CD280220C52A}.Release|x64.Build.0 = Release|Any CPU
+ {09096FEC-DA29-4914-B046-CD280220C52A}.Release|x86.ActiveCfg = Release|Any CPU
+ {09096FEC-DA29-4914-B046-CD280220C52A}.Release|x86.Build.0 = Release|Any CPU
{9CA13D2D-D6E2-4201-946C-81D1E6093404}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9CA13D2D-D6E2-4201-946C-81D1E6093404}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9CA13D2D-D6E2-4201-946C-81D1E6093404}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {9CA13D2D-D6E2-4201-946C-81D1E6093404}.Debug|x64.Build.0 = Debug|Any CPU
+ {9CA13D2D-D6E2-4201-946C-81D1E6093404}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {9CA13D2D-D6E2-4201-946C-81D1E6093404}.Debug|x86.Build.0 = Debug|Any CPU
{9CA13D2D-D6E2-4201-946C-81D1E6093404}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9CA13D2D-D6E2-4201-946C-81D1E6093404}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9CA13D2D-D6E2-4201-946C-81D1E6093404}.Release|x64.ActiveCfg = Release|Any CPU
+ {9CA13D2D-D6E2-4201-946C-81D1E6093404}.Release|x64.Build.0 = Release|Any CPU
+ {9CA13D2D-D6E2-4201-946C-81D1E6093404}.Release|x86.ActiveCfg = Release|Any CPU
+ {9CA13D2D-D6E2-4201-946C-81D1E6093404}.Release|x86.Build.0 = Release|Any CPU
{1E610F55-2D74-4856-818B-0D0B47601B75}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1E610F55-2D74-4856-818B-0D0B47601B75}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1E610F55-2D74-4856-818B-0D0B47601B75}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1E610F55-2D74-4856-818B-0D0B47601B75}.Debug|x64.Build.0 = Debug|Any CPU
+ {1E610F55-2D74-4856-818B-0D0B47601B75}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {1E610F55-2D74-4856-818B-0D0B47601B75}.Debug|x86.Build.0 = Debug|Any CPU
{1E610F55-2D74-4856-818B-0D0B47601B75}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1E610F55-2D74-4856-818B-0D0B47601B75}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1E610F55-2D74-4856-818B-0D0B47601B75}.Release|x64.ActiveCfg = Release|Any CPU
+ {1E610F55-2D74-4856-818B-0D0B47601B75}.Release|x64.Build.0 = Release|Any CPU
+ {1E610F55-2D74-4856-818B-0D0B47601B75}.Release|x86.ActiveCfg = Release|Any CPU
+ {1E610F55-2D74-4856-818B-0D0B47601B75}.Release|x86.Build.0 = Release|Any CPU
{EBFAFB5B-494F-48D5-A70D-AF1490B9260A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EBFAFB5B-494F-48D5-A70D-AF1490B9260A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EBFAFB5B-494F-48D5-A70D-AF1490B9260A}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {EBFAFB5B-494F-48D5-A70D-AF1490B9260A}.Debug|x64.Build.0 = Debug|Any CPU
+ {EBFAFB5B-494F-48D5-A70D-AF1490B9260A}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {EBFAFB5B-494F-48D5-A70D-AF1490B9260A}.Debug|x86.Build.0 = Debug|Any CPU
{EBFAFB5B-494F-48D5-A70D-AF1490B9260A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EBFAFB5B-494F-48D5-A70D-AF1490B9260A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EBFAFB5B-494F-48D5-A70D-AF1490B9260A}.Release|x64.ActiveCfg = Release|Any CPU
+ {EBFAFB5B-494F-48D5-A70D-AF1490B9260A}.Release|x64.Build.0 = Release|Any CPU
+ {EBFAFB5B-494F-48D5-A70D-AF1490B9260A}.Release|x86.ActiveCfg = Release|Any CPU
+ {EBFAFB5B-494F-48D5-A70D-AF1490B9260A}.Release|x86.Build.0 = Release|Any CPU
{54631590-2A41-45F4-B057-92C840ED08C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{54631590-2A41-45F4-B057-92C840ED08C1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {54631590-2A41-45F4-B057-92C840ED08C1}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {54631590-2A41-45F4-B057-92C840ED08C1}.Debug|x64.Build.0 = Debug|Any CPU
+ {54631590-2A41-45F4-B057-92C840ED08C1}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {54631590-2A41-45F4-B057-92C840ED08C1}.Debug|x86.Build.0 = Debug|Any CPU
{54631590-2A41-45F4-B057-92C840ED08C1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{54631590-2A41-45F4-B057-92C840ED08C1}.Release|Any CPU.Build.0 = Release|Any CPU
- {B296277A-C822-444E-8CFA-4CC4C1C1F737}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {B296277A-C822-444E-8CFA-4CC4C1C1F737}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {B296277A-C822-444E-8CFA-4CC4C1C1F737}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {B296277A-C822-444E-8CFA-4CC4C1C1F737}.Release|Any CPU.Build.0 = Release|Any CPU
- {B0AB6EA7-7D53-4457-9482-F0613F99E3BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C59F300E-4BAC-4329-9A41-8F1D75A7E197}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C59F300E-4BAC-4329-9A41-8F1D75A7E197}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C59F300E-4BAC-4329-9A41-8F1D75A7E197}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C59F300E-4BAC-4329-9A41-8F1D75A7E197}.Release|Any CPU.Build.0 = Release|Any CPU
{B0AB6EA7-7D53-4457-9482-F0613F99E3BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B0AB6EA7-7D53-4457-9482-F0613F99E3BB}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {B0AB6EA7-7D53-4457-9482-F0613F99E3BB}.Debug|x64.Build.0 = Debug|Any CPU
+ {B0AB6EA7-7D53-4457-9482-F0613F99E3BB}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {B0AB6EA7-7D53-4457-9482-F0613F99E3BB}.Debug|x86.Build.0 = Debug|Any CPU
{B0AB6EA7-7D53-4457-9482-F0613F99E3BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B0AB6EA7-7D53-4457-9482-F0613F99E3BB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B0AB6EA7-7D53-4457-9482-F0613F99E3BB}.Release|x64.ActiveCfg = Release|Any CPU
+ {B0AB6EA7-7D53-4457-9482-F0613F99E3BB}.Release|x64.Build.0 = Release|Any CPU
+ {B0AB6EA7-7D53-4457-9482-F0613F99E3BB}.Release|x86.ActiveCfg = Release|Any CPU
+ {B0AB6EA7-7D53-4457-9482-F0613F99E3BB}.Release|x86.Build.0 = Release|Any CPU
+ {ABD1EE97-DD84-4C6A-8F3F-28E7D3D898B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {ABD1EE97-DD84-4C6A-8F3F-28E7D3D898B4}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {ABD1EE97-DD84-4C6A-8F3F-28E7D3D898B4}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {ABD1EE97-DD84-4C6A-8F3F-28E7D3D898B4}.Debug|x64.Build.0 = Debug|Any CPU
+ {ABD1EE97-DD84-4C6A-8F3F-28E7D3D898B4}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {ABD1EE97-DD84-4C6A-8F3F-28E7D3D898B4}.Debug|x86.Build.0 = Debug|Any CPU
+ {ABD1EE97-DD84-4C6A-8F3F-28E7D3D898B4}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {ABD1EE97-DD84-4C6A-8F3F-28E7D3D898B4}.Release|Any CPU.Build.0 = Release|Any CPU
+ {ABD1EE97-DD84-4C6A-8F3F-28E7D3D898B4}.Release|x64.ActiveCfg = Release|Any CPU
+ {ABD1EE97-DD84-4C6A-8F3F-28E7D3D898B4}.Release|x64.Build.0 = Release|Any CPU
+ {ABD1EE97-DD84-4C6A-8F3F-28E7D3D898B4}.Release|x86.ActiveCfg = Release|Any CPU
+ {ABD1EE97-DD84-4C6A-8F3F-28E7D3D898B4}.Release|x86.Build.0 = Release|Any CPU
+ {AB5F238F-AB78-4A85-8D8D-17E211015FD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AB5F238F-AB78-4A85-8D8D-17E211015FD3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AB5F238F-AB78-4A85-8D8D-17E211015FD3}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {AB5F238F-AB78-4A85-8D8D-17E211015FD3}.Debug|x64.Build.0 = Debug|Any CPU
+ {AB5F238F-AB78-4A85-8D8D-17E211015FD3}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {AB5F238F-AB78-4A85-8D8D-17E211015FD3}.Debug|x86.Build.0 = Debug|Any CPU
+ {AB5F238F-AB78-4A85-8D8D-17E211015FD3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AB5F238F-AB78-4A85-8D8D-17E211015FD3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AB5F238F-AB78-4A85-8D8D-17E211015FD3}.Release|x64.ActiveCfg = Release|Any CPU
+ {AB5F238F-AB78-4A85-8D8D-17E211015FD3}.Release|x64.Build.0 = Release|Any CPU
+ {AB5F238F-AB78-4A85-8D8D-17E211015FD3}.Release|x86.ActiveCfg = Release|Any CPU
+ {AB5F238F-AB78-4A85-8D8D-17E211015FD3}.Release|x86.Build.0 = Release|Any CPU
+ {12BE663C-C0DD-4343-93DF-6B2D853B6B79}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {12BE663C-C0DD-4343-93DF-6B2D853B6B79}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {12BE663C-C0DD-4343-93DF-6B2D853B6B79}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {12BE663C-C0DD-4343-93DF-6B2D853B6B79}.Debug|x64.Build.0 = Debug|Any CPU
+ {12BE663C-C0DD-4343-93DF-6B2D853B6B79}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {12BE663C-C0DD-4343-93DF-6B2D853B6B79}.Debug|x86.Build.0 = Debug|Any CPU
+ {12BE663C-C0DD-4343-93DF-6B2D853B6B79}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {12BE663C-C0DD-4343-93DF-6B2D853B6B79}.Release|Any CPU.Build.0 = Release|Any CPU
+ {12BE663C-C0DD-4343-93DF-6B2D853B6B79}.Release|x64.ActiveCfg = Release|Any CPU
+ {12BE663C-C0DD-4343-93DF-6B2D853B6B79}.Release|x64.Build.0 = Release|Any CPU
+ {12BE663C-C0DD-4343-93DF-6B2D853B6B79}.Release|x86.ActiveCfg = Release|Any CPU
+ {12BE663C-C0DD-4343-93DF-6B2D853B6B79}.Release|x86.Build.0 = Release|Any CPU
+ {F64790E0-86AD-4562-9AC5-F4DD3F4881BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F64790E0-86AD-4562-9AC5-F4DD3F4881BA}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F64790E0-86AD-4562-9AC5-F4DD3F4881BA}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F64790E0-86AD-4562-9AC5-F4DD3F4881BA}.Debug|x64.Build.0 = Debug|Any CPU
+ {F64790E0-86AD-4562-9AC5-F4DD3F4881BA}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F64790E0-86AD-4562-9AC5-F4DD3F4881BA}.Debug|x86.Build.0 = Debug|Any CPU
+ {F64790E0-86AD-4562-9AC5-F4DD3F4881BA}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F64790E0-86AD-4562-9AC5-F4DD3F4881BA}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F64790E0-86AD-4562-9AC5-F4DD3F4881BA}.Release|x64.ActiveCfg = Release|Any CPU
+ {F64790E0-86AD-4562-9AC5-F4DD3F4881BA}.Release|x64.Build.0 = Release|Any CPU
+ {F64790E0-86AD-4562-9AC5-F4DD3F4881BA}.Release|x86.ActiveCfg = Release|Any CPU
+ {F64790E0-86AD-4562-9AC5-F4DD3F4881BA}.Release|x86.Build.0 = Release|Any CPU
+ {949C95E9-4261-416E-8D2A-F05E3D4640CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {949C95E9-4261-416E-8D2A-F05E3D4640CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {949C95E9-4261-416E-8D2A-F05E3D4640CA}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {949C95E9-4261-416E-8D2A-F05E3D4640CA}.Debug|x64.Build.0 = Debug|Any CPU
+ {949C95E9-4261-416E-8D2A-F05E3D4640CA}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {949C95E9-4261-416E-8D2A-F05E3D4640CA}.Debug|x86.Build.0 = Debug|Any CPU
+ {949C95E9-4261-416E-8D2A-F05E3D4640CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {949C95E9-4261-416E-8D2A-F05E3D4640CA}.Release|Any CPU.Build.0 = Release|Any CPU
+ {949C95E9-4261-416E-8D2A-F05E3D4640CA}.Release|x64.ActiveCfg = Release|Any CPU
+ {949C95E9-4261-416E-8D2A-F05E3D4640CA}.Release|x64.Build.0 = Release|Any CPU
+ {949C95E9-4261-416E-8D2A-F05E3D4640CA}.Release|x86.ActiveCfg = Release|Any CPU
+ {949C95E9-4261-416E-8D2A-F05E3D4640CA}.Release|x86.Build.0 = Release|Any CPU
+ {ADDBCE30-FE7F-4198-8A37-772EF6BF3676}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {ADDBCE30-FE7F-4198-8A37-772EF6BF3676}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {ADDBCE30-FE7F-4198-8A37-772EF6BF3676}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {ADDBCE30-FE7F-4198-8A37-772EF6BF3676}.Debug|x64.Build.0 = Debug|Any CPU
+ {ADDBCE30-FE7F-4198-8A37-772EF6BF3676}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {ADDBCE30-FE7F-4198-8A37-772EF6BF3676}.Debug|x86.Build.0 = Debug|Any CPU
+ {ADDBCE30-FE7F-4198-8A37-772EF6BF3676}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {ADDBCE30-FE7F-4198-8A37-772EF6BF3676}.Release|Any CPU.Build.0 = Release|Any CPU
+ {ADDBCE30-FE7F-4198-8A37-772EF6BF3676}.Release|x64.ActiveCfg = Release|Any CPU
+ {ADDBCE30-FE7F-4198-8A37-772EF6BF3676}.Release|x64.Build.0 = Release|Any CPU
+ {ADDBCE30-FE7F-4198-8A37-772EF6BF3676}.Release|x86.ActiveCfg = Release|Any CPU
+ {ADDBCE30-FE7F-4198-8A37-772EF6BF3676}.Release|x86.Build.0 = Release|Any CPU
+ {DF084D96-707B-47C2-9493-85FA84631ACE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {DF084D96-707B-47C2-9493-85FA84631ACE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {DF084D96-707B-47C2-9493-85FA84631ACE}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {DF084D96-707B-47C2-9493-85FA84631ACE}.Debug|x64.Build.0 = Debug|Any CPU
+ {DF084D96-707B-47C2-9493-85FA84631ACE}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {DF084D96-707B-47C2-9493-85FA84631ACE}.Debug|x86.Build.0 = Debug|Any CPU
+ {DF084D96-707B-47C2-9493-85FA84631ACE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {DF084D96-707B-47C2-9493-85FA84631ACE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {DF084D96-707B-47C2-9493-85FA84631ACE}.Release|x64.ActiveCfg = Release|Any CPU
+ {DF084D96-707B-47C2-9493-85FA84631ACE}.Release|x64.Build.0 = Release|Any CPU
+ {DF084D96-707B-47C2-9493-85FA84631ACE}.Release|x86.ActiveCfg = Release|Any CPU
+ {DF084D96-707B-47C2-9493-85FA84631ACE}.Release|x86.Build.0 = Release|Any CPU
+ {1764E3C7-CAAF-4FE9-9104-CF26D8121FC9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1764E3C7-CAAF-4FE9-9104-CF26D8121FC9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1764E3C7-CAAF-4FE9-9104-CF26D8121FC9}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1764E3C7-CAAF-4FE9-9104-CF26D8121FC9}.Debug|x64.Build.0 = Debug|Any CPU
+ {1764E3C7-CAAF-4FE9-9104-CF26D8121FC9}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {1764E3C7-CAAF-4FE9-9104-CF26D8121FC9}.Debug|x86.Build.0 = Debug|Any CPU
+ {1764E3C7-CAAF-4FE9-9104-CF26D8121FC9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1764E3C7-CAAF-4FE9-9104-CF26D8121FC9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1764E3C7-CAAF-4FE9-9104-CF26D8121FC9}.Release|x64.ActiveCfg = Release|Any CPU
+ {1764E3C7-CAAF-4FE9-9104-CF26D8121FC9}.Release|x64.Build.0 = Release|Any CPU
+ {1764E3C7-CAAF-4FE9-9104-CF26D8121FC9}.Release|x86.ActiveCfg = Release|Any CPU
+ {1764E3C7-CAAF-4FE9-9104-CF26D8121FC9}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -84,11 +256,14 @@ Global
{C02DECC9-2A82-42C0-8F26-D0AE6559AC5E} = {B4CE3CD4-74AA-4A22-B514-BC9B380AAFD7}
{09096FEC-DA29-4914-B046-CD280220C52A} = {0295DFAC-BF6E-46C0-A63D-FBE9AF3C04E5}
{9CA13D2D-D6E2-4201-946C-81D1E6093404} = {0295DFAC-BF6E-46C0-A63D-FBE9AF3C04E5}
- {1E610F55-2D74-4856-818B-0D0B47601B75} = {0295DFAC-BF6E-46C0-A63D-FBE9AF3C04E5}
{EBFAFB5B-494F-48D5-A70D-AF1490B9260A} = {B5D35D0C-387C-44FA-9A70-6FE24DAE5728}
- {54631590-2A41-45F4-B057-92C840ED08C1} = {0EAC5155-A5EA-49C1-8E0C-19DD36D2C21C}
- {B296277A-C822-444E-8CFA-4CC4C1C1F737} = {E0861852-0F5B-4810-8586-A59038BC4034}
- {B0AB6EA7-7D53-4457-9482-F0613F99E3BB} = {0EAC5155-A5EA-49C1-8E0C-19DD36D2C21C}
+ {AB5F238F-AB78-4A85-8D8D-17E211015FD3} = {0295DFAC-BF6E-46C0-A63D-FBE9AF3C04E5}
+ {12BE663C-C0DD-4343-93DF-6B2D853B6B79} = {0295DFAC-BF6E-46C0-A63D-FBE9AF3C04E5}
+ {F64790E0-86AD-4562-9AC5-F4DD3F4881BA} = {B5D35D0C-387C-44FA-9A70-6FE24DAE5728}
+ {949C95E9-4261-416E-8D2A-F05E3D4640CA} = {64880D93-BAB4-FF83-898C-B934B68C31A9}
+ {ADDBCE30-FE7F-4198-8A37-772EF6BF3676} = {64880D93-BAB4-FF83-898C-B934B68C31A9}
+ {DF084D96-707B-47C2-9493-85FA84631ACE} = {B4CE3CD4-74AA-4A22-B514-BC9B380AAFD7}
+ {1764E3C7-CAAF-4FE9-9104-CF26D8121FC9} = {0EAC5155-A5EA-49C1-8E0C-19DD36D2C21C}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {AD284F35-E81F-4678-B737-A5DC8CB883CB}
diff --git a/actions/endpoint_sahil_gdbapi.yml.20241204 b/actions/endpoint_sahil_gdbapi.yml.20241204
new file mode 100644
index 000000000..94f4b0fbd
--- /dev/null
+++ b/actions/endpoint_sahil_gdbapi.yml.20241204
@@ -0,0 +1,87 @@
+# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
+# More GitHub Actions for Azure: https://github.com/Azure/actions
+
+name: Build and deploy Good Deed Books API to Azure Web App - gdbapi
+
+on:
+ push:
+ branches:
+ - endpoint_sahil
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up .NET Core
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.x'
+
+ - name: Install dotnet-ef tool
+ run: |
+ dotnet tool install --global dotnet-ef
+ echo "++++ dotnet-ef version"
+ dotnet ef --version
+
+ - name: Build with dotnet
+ run: |
+ echo "++++ dotnet build"
+ dotnet build --configuration Release
+
+ - name: Add migrations
+ run: |
+ echo "++++ current directory"
+ pwd
+ echo "++++ add ApplicationIdentityDbContext migration M1"
+ dotnet ef migrations add M1 --project ./src/Api/ --startup-project ./src/Api/Api.csproj --msbuildprojectextensionspath .build/obj/Api/ --context ApplicationIdentityDbContext --output-dir Data/Migrations/IdentityDb
+ echo "++++ add ApiDbContext migration M2"
+ dotnet ef migrations add M2 --project ./src/Api/ --startup-project ./src/Api/Api.csproj --msbuildprojectextensionspath .build/obj/Api/ --context ApiDbContext --output-dir Data/Migrations/ApiDb
+ echo "++++ contents of ./src/Api/Data/Migrations/IdentityDb"
+ ls ./src/Api/Data/Migrations/IdentityDb
+ echo "++++ contents of ./src/Api/Data/Migrations/ApiDb"
+ ls ./src/Api/Data/Migrations/ApiDb
+
+ - name: dotnet publish
+ run: |
+ echo "++++ contents of dotnet publish ./src/Api/Api.csproj"
+ dotnet publish ./src/Api/Api.csproj -f net8.0 -c Release -o "${{runner.temp}}/myapp"
+
+ - name: Upload artifact for deployment job
+ uses: actions/upload-artifact@v4
+ with:
+ name: .net-app
+ path: ${{runner.temp}}/myapp
+
+ deploy:
+ runs-on: windows-latest
+ needs: build
+ environment:
+ name: 'Production'
+ url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
+ permissions:
+ id-token: write #This is required for requesting the JWT
+
+ steps:
+ - name: Download artifact from build job
+ uses: actions/download-artifact@v4
+ with:
+ name: .net-app
+
+ - name: Login to Azure
+ uses: azure/login@v2
+ with:
+ client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_A17E281C175C4E629A76134AA823BAC5 }}
+ tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_258CF23452C24D9795BD94B25EF50B73 }}
+ subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_9375B274C69740D39F4770D5D433E8B1 }}
+
+ - name: Deploy to Azure Web App
+ id: deploy-to-webapp
+ uses: azure/webapps-deploy@v3
+ with:
+ app-name: 'gdbapi'
+ slot-name: 'Production'
+ package: .
diff --git a/actions/endpoint_sahil_gdbmvc.yml.20241204 b/actions/endpoint_sahil_gdbmvc.yml.20241204
new file mode 100644
index 000000000..330b665ab
--- /dev/null
+++ b/actions/endpoint_sahil_gdbmvc.yml.20241204
@@ -0,0 +1,101 @@
+# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
+# More GitHub Actions for Azure: https://github.com/Azure/actions
+
+name: Build and deploy Good Deed Books MVC project to Azure
+
+on:
+ push:
+ branches:
+ - endpoint_sahil
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up .NET Core
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.x'
+ include-prerelease: true
+
+ - name: Build with dotnet
+ run: dotnet build --configuration Release
+
+ - name: dotnet publish
+ run: dotnet publish ./src/AccountGoWeb/AccountGoWeb.csproj -c Release -o ${{env.DOTNET_ROOT}}/myapp
+
+ - name: Archive production artifacts
+ run: |
+ echo "+++++++++++++++++++++++++ where am I? ++++++++++++++++++++++++"
+ pwd
+ echo "+++++++++++++++++++++++++ save current directory into a variable dir ++++"
+ dir=$(pwd)
+ echo "+++++++++ what is in variable dir ++++++++++++++"
+ echo $dir
+ echo "++++++++++++++++++++++++ what's in current directory? ++++++++"
+ ls -al
+ echo "+++++ what's in the ${{env.DOTNET_ROOT}}/myapp directory? ++++"
+ ls -al ${{env.DOTNET_ROOT}}/myapp
+ echo "+++++ change directoiry to ${{env.DOTNET_ROOT}}/myapp ++++"
+ cd ${{env.DOTNET_ROOT}}/myapp
+ echo "+++++++++++++++++++++++++ where am I? ++++++++++++++++++++++++"
+ pwd
+ echo "+++++++++++++++++++++++++ compress current directory and save in $dir/my_artifact.tar.gz ++++"
+ tar -czvf $dir/my_artifact.tar.gz .
+ echo "+++++++++++++++++++++++++ change dir to to $dir directory ++++"
+ cd $dir
+ echo "+++++++++++++++++++++++++ where am I? ++++++++++++++++++++++++"
+ pwd
+ echo "++++++++++++++++++++++++ what's in $dir directory? ++++++++"
+ ls -al
+
+ - name: Upload artifact for deployment job
+ uses: actions/upload-artifact@v4
+ with:
+ name: .net-app
+ path: my_artifact.tar.gz
+
+ deploy:
+ runs-on: ubuntu-latest
+ needs: build
+ environment:
+ name: 'Production'
+ url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
+ permissions:
+ id-token: write #This is required for requesting the JWT
+
+ steps:
+ - name: Download artifact from build job
+ uses: actions/download-artifact@v4
+ with:
+ name: .net-app
+
+ - name: Login to Azure
+ uses: azure/login@v2
+ with:
+ client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_8B6389BB3F37413FB2483AC2574C3BCB }}
+ tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_FD62C59DE5DC42C2A07DB8191A522348 }}
+ subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_7076EF307FDA4C11BC99A0A7A0943794 }}
+
+ - name: Extract artifacts
+ run: |
+ tar -xzvf my_artifact.tar.gz -C .
+
+ - name: Print working directory
+ run: pwd
+
+ - name: List directory contents
+ run: ls -l /home/runner/.dotnet/
+
+ - name: Deploy to Azure Web App
+ id: deploy-to-webapp
+ uses: azure/webapps-deploy@v3
+ with:
+ app-name: 'gdbmvc'
+ slot-name: 'Production'
+ package: .
+
\ No newline at end of file
diff --git a/actions/gdb-blazor.yml.gold b/actions/gdb-blazor.yml.gold
new file mode 100644
index 000000000..7691040f1
--- /dev/null
+++ b/actions/gdb-blazor.yml.gold
@@ -0,0 +1,76 @@
+# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
+# More GitHub Actions for Azure: https://github.com/Azure/actions
+
+name: Build and deploy Good Deed Books BLAZOR project to Azure
+
+on:
+ push:
+ branches:
+ - endpoint_sahil
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up .NET Core
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.x'
+
+ - name: Build with dotnet
+ working-directory: ./src/BlazorGDB/BlazorGDB
+ run: dotnet build --configuration Release
+
+ - name: dotnet publish
+ working-directory: ./src/BlazorGDB/BlazorGDB
+ run: dotnet publish BlazorGDB.csproj -c Release -o ${{env.DOTNET_ROOT}}/myapp
+
+ - name: sanity check
+ run: |
+ echo "+++++++++++++++++++++++++ where am I? ++++++++++++++++++++++++"
+ pwd
+ echo "++++++++++++++++++++++++ what's in current directory? ++++++++"
+ ls -al
+ echo "+++++ what's in the ${{env.DOTNET_ROOT}}/myapp directory? ++++"
+ ls -al ${{env.DOTNET_ROOT}}/myapp
+
+ - name: Upload artifact for deployment job
+ uses: actions/upload-artifact@v4
+ with:
+ name: .net-app
+ path: ${{env.DOTNET_ROOT}}/myapp
+
+ deploy:
+ runs-on: ubuntu-latest
+ needs: build
+ environment:
+ name: 'Production'
+ url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
+ permissions:
+ id-token: write #This is required for requesting the JWT
+
+ steps:
+ - name: Download artifact from build job
+ uses: actions/download-artifact@v4
+ with:
+ name: .net-app
+
+ - name: Login to Azure
+ uses: azure/login@v2
+ with:
+ client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_6A854C1CD0C74473AD2E3B9F843CC396 }}
+ tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_224A065E650B4D5F9EB2329B6B2F1716 }}
+ subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_570B031F0942445C8E479905EE706F43 }}
+
+ - name: Deploy to Azure Web App
+ id: deploy-to-webapp
+ uses: azure/webapps-deploy@v3
+ with:
+ app-name: 'gdb-blazor'
+ slot-name: 'Production'
+ package: .
+
\ No newline at end of file
diff --git a/actions/gdb_api.yml.flat b/actions/gdb_api.yml.flat
new file mode 100644
index 000000000..9218db27d
--- /dev/null
+++ b/actions/gdb_api.yml.flat
@@ -0,0 +1,91 @@
+# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
+# More GitHub Actions for Azure: https://github.com/Azure/actions
+
+name: Build and deploy Good Deed Books API project to Azure
+
+on:
+ push:
+ branches:
+ - endpoint_sahil
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up .NET Core
+ uses: actions/setup-dotnet@v1
+ with:
+ dotnet-version: '8.x'
+ include-prerelease: true
+
+ - name: Install dotnet-ef tool
+ run: |
+ dotnet tool install --global dotnet-ef
+ echo "++++ dotnet-ef version"
+ dotnet ef --version
+
+ - name: Build with dotnet
+ run: |
+ echo "++++ dotnet restore"
+ dotnet restore
+ echo "++++ dotnet build"
+ dotnet build --configuration Release
+
+ - name: Add migrations
+ run: |
+ echo "++++ current directory"
+ pwd
+ echo "++++ add ApplicationIdentityDbContext migration M1"
+ dotnet ef migrations add M1 --project ./src/Api/ --startup-project ./src/Api/Api.csproj --msbuildprojectextensionspath .build/obj/Api/ --context ApplicationIdentityDbContext --output-dir Data/Migrations/IdentityDb
+ echo "++++ add ApiDbContext migration M2"
+ dotnet ef migrations add M2 --project ./src/Api/ --startup-project ./src/Api/Api.csproj --msbuildprojectextensionspath .build/obj/Api/ --context ApiDbContext --output-dir Data/Migrations/ApiDb
+ echo "++++ contents of ./src/Api/Data/Migrations/IdentityDb"
+ ls ./src/Api/Data/Migrations/IdentityDb
+ echo "++++ contents of ./src/Api/Data/Migrations/ApiDb"
+ ls ./src/Api/Data/Migrations/ApiDb
+
+ - name: dotnet publish
+ run: |
+ echo "++++ contents of dotnet publish ./src/Api/Api.csproj"
+ dotnet publish ./src/Api/Api.csproj -c Release -o ${{env.DOTNET_ROOT}}/myapp
+
+ - name: Upload artifact for deployment job
+ uses: actions/upload-artifact@v3
+ with:
+ name: .net-app
+ path: ${{env.DOTNET_ROOT}}/myapp
+
+ deploy:
+ runs-on: ubuntu-latest
+ needs: build
+ environment:
+ name: 'Production'
+ url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
+ permissions:
+ id-token: write #This is required for requesting the JWT
+
+ steps:
+ - name: Download artifact from build job
+ uses: actions/download-artifact@v3
+ with:
+ name: .net-app
+
+ - name: Login to Azure
+ uses: azure/login@v1
+ with:
+ client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_543326D87AEF459D91E15D756166A5AC }}
+ tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_D57EB2BACAA54EE2AB97F696E8E99A4B }}
+ subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_3C797712E9A047958FF5C9BB540F0543 }}
+
+ - name: Deploy to Azure Web App
+ id: deploy-to-webapp
+ uses: azure/webapps-deploy@v2
+ with:
+ app-name: 'goodbooksapi'
+ slot-name: 'Production'
+ package: .
+
diff --git a/actions/gdb_mvc_tar.yml.flat b/actions/gdb_mvc_tar.yml.flat
new file mode 100644
index 000000000..0b749010a
--- /dev/null
+++ b/actions/gdb_mvc_tar.yml.flat
@@ -0,0 +1,101 @@
+# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
+# More GitHub Actions for Azure: https://github.com/Azure/actions
+
+name: Build and deploy Good Deed Books MVC project to Azure
+
+on:
+ push:
+ branches:
+ - endpoint_sahil
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up .NET Core
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.x'
+ include-prerelease: true
+
+ - name: Build with dotnet
+ run: dotnet build --configuration Release
+
+ - name: dotnet publish
+ run: dotnet publish ./src/AccountGoWeb/AccountGoWeb.csproj -c Release -o ${{env.DOTNET_ROOT}}/myapp
+
+ - name: Archive production artifacts
+ run: |
+ echo "+++++++++++++++++++++++++ where am I? ++++++++++++++++++++++++"
+ pwd
+ echo "+++++++++++++++++++++++++ save current directory into a variable dir ++++"
+ dir=$(pwd)
+ echo "+++++++++ what is in variable dir ++++++++++++++"
+ echo $dir
+ echo "++++++++++++++++++++++++ what's in current directory? ++++++++"
+ ls -al
+ echo "+++++ what's in the ${{env.DOTNET_ROOT}}/myapp directory? ++++"
+ ls -al ${{env.DOTNET_ROOT}}/myapp
+ echo "+++++ change directoiry to ${{env.DOTNET_ROOT}}/myapp ++++"
+ cd ${{env.DOTNET_ROOT}}/myapp
+ echo "+++++++++++++++++++++++++ where am I? ++++++++++++++++++++++++"
+ pwd
+ echo "+++++++++++++++++++++++++ compress current directory and save in $dir/my_artifact.tar.gz ++++"
+ tar -czvf $dir/my_artifact.tar.gz .
+ echo "+++++++++++++++++++++++++ change dir to to $dir directory ++++"
+ cd $dir
+ echo "+++++++++++++++++++++++++ where am I? ++++++++++++++++++++++++"
+ pwd
+ echo "++++++++++++++++++++++++ what's in $dir directory? ++++++++"
+ ls -al
+
+ - name: Upload artifact for deployment job
+ uses: actions/upload-artifact@v4
+ with:
+ name: .net-app
+ path: my_artifact.tar.gz
+
+ deploy:
+ runs-on: ubuntu-latest
+ needs: build
+ environment:
+ name: 'Production'
+ url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
+ permissions:
+ id-token: write #This is required for requesting the JWT
+
+ steps:
+ - name: Download artifact from build job
+ uses: actions/download-artifact@v4
+ with:
+ name: .net-app
+
+ - name: Login to Azure
+ uses: azure/login@v2
+ with:
+ client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_28108B2CCE81480BB0295B2554B37231 }}
+ tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_9ED1B649A03F45E7B34C3BE1217B6BDE }}
+ subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_A41842A963384E4BAB26580EEFE65E92 }}
+
+ - name: Extract artifacts
+ run: |
+ tar -xzvf my_artifact.tar.gz -C .
+
+ - name: Print working directory
+ run: pwd
+
+ - name: List directory contents
+ run: ls -l /home/runner/.dotnet/
+
+ - name: Deploy to Azure Web App
+ id: deploy-to-webapp
+ uses: azure/webapps-deploy@v3
+ with:
+ app-name: 'good-books'
+ slot-name: 'Production'
+ package: .
+
diff --git a/actions/gdbblazor.yml b/actions/gdbblazor.yml
new file mode 100644
index 000000000..a372a08c3
--- /dev/null
+++ b/actions/gdbblazor.yml
@@ -0,0 +1,67 @@
+# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
+# More GitHub Actions for Azure: https://github.com/Azure/actions
+
+name: Build and deploy Good Deed Books BLAZOR project to Azure
+
+on:
+ push:
+ branches:
+ - endpoint_sahil
+
+jobs:
+ build:
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Set up .NET Core
+ uses: actions/setup-dotnet@v2
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Restore dependencies
+ run: dotnet restore ./src/BlazorGDB/BlazorGDB/BlazorGDB.csproj
+
+ - name: Build
+ run: dotnet build ./src/BlazorGDB/BlazorGDB/BlazorGDB.csproj --configuration Release
+
+ - name: Publish
+ run: dotnet publish ./src/BlazorGDB/BlazorGDB/BlazorGDB.csproj --configuration Release --output ${{ github.workspace }}/myapp
+
+ - name: Upload artifact for deployment job
+ uses: actions/upload-artifact@v3
+ with:
+ name: .net-app
+ path: ${{ github.workspace }}/myapp
+
+ deploy:
+ runs-on: windows-latest
+ needs: build
+ environment:
+ name: 'Production'
+ url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
+ permissions:
+ id-token: write #This is required for requesting the JWT
+
+ steps:
+ - name: Download artifact from build job
+ uses: actions/download-artifact@v3
+ with:
+ name: .net-app
+
+ - name: Login to Azure
+ uses: azure/login@v2
+ with:
+ client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_C7C01847F7FC4BBFB72DEAC64242E5A4 }}
+ tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_21069DC407434A3591399953BE45ED78 }}
+ subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_CC5D4E473B8345BA854EA230A48D8D20 }}
+
+ - name: Deploy to Azure Web App
+ id: deploy-to-webapp
+ uses: azure/webapps-deploy@v3
+ with:
+ app-name: 'gdbblazor'
+ slot-name: 'Production'
+ package: .
+
diff --git a/actions/good-books_mvc.yml.disable b/actions/good-books_mvc.yml.disable
new file mode 100644
index 000000000..659083c9a
--- /dev/null
+++ b/actions/good-books_mvc.yml.disable
@@ -0,0 +1,75 @@
+# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
+# More GitHub Actions for Azure: https://github.com/Azure/actions
+
+name: Build and deploy GoodBooks MVC project to Azure
+
+on:
+ push:
+ branches:
+ - endpoint_sahil
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up .NET Core
+ uses: actions/setup-dotnet@v1
+ with:
+ dotnet-version: '8.x'
+ include-prerelease: true
+
+ - name: Build with dotnet
+ run: dotnet build --configuration Release
+
+ - name: dotnet publish
+ run: dotnet publish ./src/AccountGoWeb/AccountGoWeb.csproj -c Release -o ${{env.DOTNET_ROOT}}/myapp
+
+ # - name: Archive production artifacts
+ # run: |
+ # tar -czvf my_artifact.tar.gz ${{env.DOTNET_ROOT}}/myapp
+
+ - name: Upload artifact for deployment job
+ uses: actions/upload-artifact@v3
+ with:
+ name: .net-app
+ # path: my_artifact.tar.gz
+ path: ${{env.DOTNET_ROOT}}/myapp
+
+ deploy:
+ runs-on: ubuntu-latest
+ needs: build
+ environment:
+ name: 'Production'
+ url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
+ permissions:
+ id-token: write #This is required for requesting the JWT
+
+ steps:
+ - name: Download artifact from build job
+ uses: actions/download-artifact@v3
+ with:
+ name: .net-app
+
+ - name: Login to Azure
+ uses: azure/login@v1
+ with:
+ client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_28108B2CCE81480BB0295B2554B37231 }}
+ tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_9ED1B649A03F45E7B34C3BE1217B6BDE }}
+ subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_A41842A963384E4BAB26580EEFE65E92 }}
+
+ # - name: Extract artifacts
+ # run: |
+ # tar -xzvf my_artifact.tar.gz -C .
+
+ - name: Deploy to Azure Web App
+ id: deploy-to-webapp
+ uses: azure/webapps-deploy@v2
+ with:
+ app-name: 'good-books'
+ slot-name: 'Production'
+ package: .
+
diff --git a/actions/good-books_react.yml.disable b/actions/good-books_react.yml.disable
new file mode 100644
index 000000000..99103e5e5
--- /dev/null
+++ b/actions/good-books_react.yml.disable
@@ -0,0 +1,54 @@
+name: Azure Static Web Apps CI/CD
+
+on:
+ push:
+ branches:
+ - endpoint_sahil
+ pull_request:
+ types: [opened, synchronize, reopened, closed]
+ branches:
+ - endpoint_sahil
+
+jobs:
+ build_and_deploy_job:
+ if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
+ runs-on: ubuntu-latest
+ name: Build and Deploy Job
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ submodules: true
+ lfs: false
+
+ - name: Replace API URL
+ run: |
+ echo "++++ search & replace API URL from http://localhost:8001 to https://goodbooksapi.azurewebsites.net"
+ sed -i 's|http://localhost:8001|https://goodbooksapi.azurewebsites.net|g' ./src/GoodBooksReact/src/components/Shared/Config/index.tsx
+ echo "++++ display contents of index.tsx after search & replace"
+ cat ./src/GoodBooksReact/src/components/Shared/Config/index.tsx
+
+ - name: Build And Deploy
+ id: builddeploy
+ uses: Azure/static-web-apps-deploy@v1
+ with:
+ azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_MANGO_GLACIER_0EDFEC41E }}
+ repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
+ action: "upload"
+ ###### Repository/Build Configurations - These values can be configured to match your app requirements. ######
+ # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
+ app_location: "/src/GoodBooksReact/" # App source code path
+ api_location: "" # Api source code path - optional
+ output_location: "/dist" # Built app content directory - optional
+ ###### End of Repository/Build Configurations ######
+
+ close_pull_request_job:
+ if: github.event_name == 'pull_request' && github.event.action == 'closed'
+ runs-on: ubuntu-latest
+ name: Close Pull Request Job
+ steps:
+ - name: Close Pull Request
+ id: closepullrequest
+ uses: Azure/static-web-apps-deploy@v1
+ with:
+ azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_MANGO_GLACIER_0EDFEC41E }}
+ action: "close"
diff --git a/actions/mvc_tar.yml.works b/actions/mvc_tar.yml.works
new file mode 100644
index 000000000..0e57f5cfc
--- /dev/null
+++ b/actions/mvc_tar.yml.works
@@ -0,0 +1,92 @@
+# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
+# More GitHub Actions for Azure: https://github.com/Azure/actions
+name: Build and deploy Good Deed Books MVC project to Azure
+on:
+ push:
+ branches:
+ - endpoint_sahil
+ workflow_dispatch:
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up .NET Core
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.x'
+ include-prerelease: true
+
+ - name: Build with dotnet
+ run: dotnet build --configuration Release
+
+ - name: dotnet publish
+ run: dotnet publish ./src/AccountGoWeb/AccountGoWeb.csproj -c Release -o ${{runner.temp}}/myapp
+
+ - name: Archive production artifacts
+ run: |
+ echo "+++++++++++++++++++++++++ where am I? ++++++++++++++++++++++++"
+ pwd
+ echo "+++++++++++++++++++++++++ save current directory into a variable dir ++++"
+ dir=$(pwd)
+ echo "+++++++++ what is in variable dir ++++++++++++++"
+ echo $dir
+ echo "++++++++++++++++++++++++ what's in current directory? ++++++++"
+ ls -al
+ echo "+++++ what's in the ${{runner.temp}}/myapp directory? ++++"
+ ls -al ${{runner.temp}}/myapp
+ echo "+++++ change directory to ${{runner.temp}}/myapp ++++"
+ cd ${{runner.temp}}/myapp
+ echo "+++++++++++++++++++++++++ where am I? ++++++++++++++++++++++++"
+ pwd
+ echo "+++++++++++++++++++++++++ compress current directory and save in $dir/my_artifact.tar.gz ++++"
+ tar -czvf $dir/my_artifact.tar.gz .
+ echo "+++++++++++++++++++++++++ change back to $dir directory ++++"
+ cd $dir
+ echo "++++++++++++++++++++++++ what's in $dir directory? ++++++++"
+ ls -al
+
+ - name: Upload artifact for deployment job
+ uses: actions/upload-artifact@v4
+ with:
+ name: .net-app
+ path: my_artifact.tar.gz
+
+ deploy:
+ runs-on: ubuntu-latest
+ needs: build
+ environment:
+ name: 'Production'
+ url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
+ permissions:
+ id-token: write #This is required for requesting the JWT
+
+ steps:
+ - name: Download artifact from build job
+ uses: actions/download-artifact@v4
+ with:
+ name: .net-app
+
+ - name: Login to Azure
+ uses: azure/login@v2
+ with:
+ client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_8B6389BB3F37413FB2483AC2574C3BCB }}
+ tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_FD62C59DE5DC42C2A07DB8191A522348 }}
+ subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_7076EF307FDA4C11BC99A0A7A0943794 }}
+
+ - name: Extract artifacts
+ run: |
+ tar -xzvf my_artifact.tar.gz -C .
+
+ - name: Set startup command
+ run: |
+ az webapp config set --resource-group goodbooks-RG --name gdbmvc --startup-file "dotnet /home/site/wwwroot/GoodBooks.dll"
+
+ - name: Deploy to Azure Web App
+ id: deploy-to-webapp
+ uses: azure/webapps-deploy@v2
+ with:
+ app-name: 'gdbmvc'
+ slot-name: 'Production'
+ package: .
diff --git a/db/scripts/initial_data/3_InitialData-0001-Audit.sql b/db/scripts/initial_data/3_InitialData-0001-Audit.sql
new file mode 100644
index 000000000..b16be7c0d
--- /dev/null
+++ b/db/scripts/initial_data/3_InitialData-0001-Audit.sql
@@ -0,0 +1,13 @@
+-- Add audit data for the Company table
+INSERT INTO [dbo].[AuditableEntity] ([EntityName], [EnableAudit]) VALUES ('Company', 1);
+
+DECLARE @auditableEntityId INT;
+SELECT @auditableEntityId = [Id] FROM [dbo].[AuditableEntity] WHERE [EntityName] = 'Company';
+
+-- Add attributes for the Company table
+INSERT INTO [dbo].[AuditableAttribute] ([AuditableEntityId], [AttributeName], [EnableAudit])
+VALUES
+ (@auditableEntityId, 'CompanyCode', 1),
+ (@auditableEntityId, 'Name', 1),
+ (@auditableEntityId, 'ShortName', 1),
+ (@auditableEntityId, 'CRA', 1);
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index c7fef8216..75fe7a32d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,5 @@
version: "3"
-services:
+services:
api:
image: accountgo/accountgoapi
build:
@@ -8,14 +8,7 @@ services:
ports:
- "8001:8001"
environment:
- # - ASPNETCORE_ENVIRONMENT=Production
- ASPNETCORE_URLS=http://+:8001
- # - DBSERVER=localhost
- # - DBUSERID=dbuser
- # - DBPASSWORD=Str0ngPassword
- # - DBNAME=accountgodb
- # depends_on:
- # - db
web:
image: accountgo/accountgoweb
build:
@@ -24,13 +17,18 @@ services:
ports:
- "8000:8000"
environment:
- # - ASPNETCORE_ENVIRONMENT=Production
- ASPNETCORE_URLS=http://+:8000
- APIHOST=api
- # db:
- # image: microsoft/mssql-server-linux
- # ports:
- # - "1433:1433"
- # environment:
- # SA_PASSWORD: "Str0ngPassword"
- # ACCEPT_EULA: "Y"
\ No newline at end of file
+ sqlserver:
+ image: mcr.microsoft.com/mssql/server:2022-latest
+ container_name: gdb-sql-server
+ environment:
+ - ACCEPT_EULA=Y
+ - MSSQL_SA_PASSWORD=YourStrong!Passw0rd
+ ports:
+ - "1433:1433"
+ volumes:
+ - sqlserver_data:/var/opt/mssql
+
+volumes:
+ sqlserver_data:
\ No newline at end of file
diff --git a/docs/Bootstrap Blazor.txt b/docs/Bootstrap Blazor.txt
new file mode 100644
index 000000000..97341be99
--- /dev/null
+++ b/docs/Bootstrap Blazor.txt
@@ -0,0 +1,93 @@
+https://github.com/vikramlearning/blazorbootstrap-starter-templates/tree/master
+
+
+dotnet add package Blazor.Bootstrap -v 3.0.0-preview.2
+
+Program.cs
+
+ builder.Services.AddBlazorBootstrap(); // Add this line
+
+_Imports.razor
+
+ @using BlazorBootstrap;
+
+Delete wwwroot/bootstrap folder
+
+Replace MainLayout.razor with:
+
+ @inherits LayoutComponentBase
+
+
+
+
+
+
+
+
+
+ @Body
+
+
+
+
+
+ @code {
+ Sidebar sidebar;
+ IEnumerable navItems;
+
+ private async Task SidebarDataProvider(SidebarDataProviderRequest request)
+ {
+ if (navItems is null)
+ navItems = GetNavItems();
+
+ return await Task.FromResult(request.ApplyTo(navItems));
+ }
+
+ private IEnumerable GetNavItems()
+ {
+ navItems = new List
+ {
+ new NavItem { Id = "1", Href = "/", IconName = IconName.HouseDoorFill, Text = "Home", Match=NavLinkMatch.All},
+ new NavItem { Id = "2", Href = "/counter", IconName = IconName.PlusSquareFill, Text = "Counter"},
+ new NavItem { Id = "3", Href = "/weather", IconName = IconName.Table, Text = "Fetch Data"},
+ };
+
+ return navItems;
+ }
+ }
+
+
+
+ An unhandled error has occurred.
+
Reload
+
🗙
+
+
+App.razor
+
+ 1) Delete >>
+ 2) Add these lines at top of file under
+
+
+
+
+
+ 3) Add these lines at bottom of file under
+
+
+
+
+
+
+
+
+ 4) Change to:
+
+
+
+
\ No newline at end of file
diff --git a/docs/GoodDeedBooks.docx b/docs/GoodDeedBooks.docx
new file mode 100644
index 000000000..30ae3bee2
Binary files /dev/null and b/docs/GoodDeedBooks.docx differ
diff --git a/docs/README.md b/docs/README.md
index 74552fce9..c4965d29f 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -128,7 +128,7 @@ At this point, your database has no data on it. But there is already an initial
- Items
- Banks
-To initialize a company, call the api endpoint directly http://localhost:8001/api/administration/initializedcompany from the browser or by using curl e.g. `curl http://localhost:8001/api/administration/initializedcompany`. If you encounter some issues, the easy way for now is recreate your database and repeat the `Publish Database` section.
+To initialize a company, call the api endpoint directly http://localhost:8001/api/administration/setup from the browser or by using curl e.g. `curl http://localhost:8001/api/administration/setup`. If you encounter some issues, the easy way for now is recreate your database and repeat the `Publish Database` section.
## Build and Run "Api" (Back-end)
1. Navigate directory to `src/Api` project
@@ -181,7 +181,7 @@ To run everything (database, api, web) in docker container you can use docker-co
1. Database instance running in docker container and you can connect to it
1. You should have a running "Api" and can test it by getting the list of customers e.g. http://localhost:8001/api/sales customers
1. You can browse the UI from http://localhost:8000 and able to login to the system using initial username/password: admin@accountgo.ph/P@ssword1
-1. Initialize data by calling a special api endpoint directly. http://localhost:8001/api/administration/initializedcompany
+1. Initialize data by calling a special api endpoint directly: http://localhost:8001/api/administration/setup
# Technology Stack
- ASP.NET Core 3.1
@@ -207,4 +207,4 @@ If you are a developer and wanted to take part as contributor/collaborator we ar
So go ahead, add your code and make your first pull request.
# Contact Support
-Feel free to email mvpsolution@gmail.com of any questions.
\ No newline at end of file
+Feel free to email mvpsolution@gmail.com of any questions.
diff --git a/docs/azure.txt b/docs/azure.txt
new file mode 100644
index 000000000..1e437cd3d
--- /dev/null
+++ b/docs/azure.txt
@@ -0,0 +1,8 @@
+API:
+https://goodbooksapi.azurewebsites.net
+
+MVC:
+https://good-books.azurewebsites.net
+
+React:
+https://mango-glacier-0edfec41e.5.azurestaticapps.net
diff --git a/docs/background.txt b/docs/background.txt
new file mode 100644
index 000000000..dfd5d92f3
--- /dev/null
+++ b/docs/background.txt
@@ -0,0 +1,41 @@
+I was asked by a non-profit organization to help them find a cheap accounting system instead of paying high subscription fees from a current vendor. I stumbled upon this open source project on GitHub:
+
+https://github.com/AccountGo/accountgo
+
+It is based on the following technologies:
+
+Backend: ASP.NET WebAPI and MVC
+Frontend: React with TypeScript
+Database: SQL Server
+
+It seems that development on this app stopped about seven years ago. When I looked at it, I figured that it has most of what is needed and could be brought up to snuff by upgrading the application to the latest state of .NET and React. Therefore, I forked it and updated it to the latest versions of .NET, React, and TypeScript.
+
+The forked app is at https://github.com/medhatelmasry/GoodBooks
+
+You can run it by following these steps:
+
+Clone the repo
+Start SQL Server in a docker container with:
+
+ docker run --cap-add SYS_PTRACE -e ACCEPT_EULA=1 -e MSSQL_SA_PASSWORD=SqlPassword! -p 1444:1433 --name azsql -d mcr.microsoft.com/azure-sql-edge
+
+In root directory of the code, run the following commands:
+
+ dotnet ef migrations add M1 --project ./src/Api/ --startup-project ./src/Api/Api.csproj --msbuildprojectextensionspath .build/obj/Api/ --context ApplicationIdentityDbContext --output-dir Data/Migrations/IdentityDb
+
+ dotnet ef migrations add M2 --project ./src/Api/ --startup-project ./src/Api/Api.csproj --msbuildprojectextensionspath .build/obj/Api/ --context ApiDbContext --output-dir Data/Migrations/ApiDb
+
+ dotnet ef database update --project ./src/Api/ --msbuildprojectextensionspath .build/obj/Api/ --context ApplicationIdentityDbContext
+
+ dotnet ef database update --project ./src/Api/ --msbuildprojectextensionspath .build/obj/Api/ --context ApiDbContext
+
+Update to the latest versions of Node & Npm
+Go to the src/Api folder and start the WebAPI app with: dotnet watch
+Hit this endpoint in order to populate the database with some sample data: http://localhost:8001/api/administration/setup
+In a separate terminal window, go to the src/GoodBooksReact folder run these commands:
+
+ npm install
+ npm run dev
+
+The React app will run. It is a rudimentary frontend menu system and is a work in progress.
+
diff --git a/docs/expand-chart-of-accounts.docx b/docs/expand-chart-of-accounts.docx
new file mode 100644
index 000000000..0ebf39cf4
Binary files /dev/null and b/docs/expand-chart-of-accounts.docx differ
diff --git a/docs/medhat.txt b/docs/medhat.txt
new file mode 100644
index 000000000..b66e10c56
--- /dev/null
+++ b/docs/medhat.txt
@@ -0,0 +1,28 @@
+docker run --cap-add SYS_PTRACE -e ACCEPT_EULA=1 -e MSSQL_SA_PASSWORD=SqlPassword! -p 1444:1433 --name azsql -d mcr.microsoft.com/azure-sql-edge
+
+Data Source=localhost,1444;Database=Northwind;Persist Security Info=True;User ID=sa;Password=SqlPassword!;TrustServerCertificate=True;
+
+====================
+
+dotnet ef migrations add M1 --project ./src/Api/ --startup-project ./src/Api/Api.csproj --msbuildprojectextensionspath .build/obj/Api/ --context ApplicationIdentityDbContext --output-dir Data/Migrations/IdentityDb
+
+dotnet ef migrations add M2 --project ./src/Api/ --startup-project ./src/Api/Api.csproj --msbuildprojectextensionspath .build/obj/Api/ --context ApiDbContext --output-dir Data/Migrations/ApiDb
+
+====================
+
+dotnet ef database update --project ./src/Api/ --msbuildprojectextensionspath .build/obj/Api/ --context ApplicationIdentityDbContext
+
+dotnet ef database update --project ./src/Api/ --msbuildprojectextensionspath .build/obj/Api/ --context ApiDbContext
+
+====================
+
+Update to the latest versions of node & npm
+
+====================
+
+Start the API .NET application then hit this endpoint in a browser to create seed data:
+http://localhost:8001/api/administration/setup
+
+
+
+
diff --git a/docs/open-source.txt b/docs/open-source.txt
new file mode 100644
index 000000000..38db29dbc
--- /dev/null
+++ b/docs/open-source.txt
@@ -0,0 +1,3 @@
+Open Source Accounting System
+
+https://github.com/AccountGo/accountgo
diff --git a/docs/pr.txt b/docs/pr.txt
new file mode 100644
index 000000000..b5b7b5215
--- /dev/null
+++ b/docs/pr.txt
@@ -0,0 +1,19 @@
+git checkout -b dotnet_9 origin/dotnet_9
+
+docker run --cap-add SYS_PTRACE -e ACCEPT_EULA=1 -e MSSQL_SA_PASSWORD=SqlPassword! -p 1444:1433 --name sql -d mcr.microsoft.com/mssql/server:2022-latest
+
+---------
+
+dotnet ef migrations add M1 --project ./src/Api/ --startup-project ./src/Api/Api.csproj --msbuildprojectextensionspath .build/obj/Api/ --context ApplicationIdentityDbContext --output-dir Data/Migrations/IdentityDb
+
+dotnet ef migrations add M2 --project ./src/Api/ --startup-project ./src/Api/Api.csproj --msbuildprojectextensionspath .build/obj/Api/ --context ApiDbContext --output-dir Data/Migrations/ApiDb
+
+dotnet ef database update --project ./src/Api/ --msbuildprojectextensionspath .build/obj/Api/ --context ApplicationIdentityDbContext
+
+dotnet ef database update --project ./src/Api/ --msbuildprojectextensionspath .build/obj/Api/ --context ApiDbContext
+
+==========
+
+Apply Entity Framework Core migrations in .NET Aspire
+ https://learn.microsoft.com/en-us/dotnet/aspire/database/ef-core-migrations
+
diff --git a/docs/react.txt b/docs/react.txt
new file mode 100644
index 000000000..2e56c21d6
--- /dev/null
+++ b/docs/react.txt
@@ -0,0 +1,4 @@
+https://www.youtube.com/watch?v=ElgfQdq-Htk
+
+https://www.youtube.com/watch?v=oN9W0Tkn8hg
+
diff --git a/move-to-blazor.txt b/move-to-blazor.txt
new file mode 100644
index 000000000..e388316b6
--- /dev/null
+++ b/move-to-blazor.txt
@@ -0,0 +1,9 @@
+Recreate starter Blazor app with database authentication
+- we will use JWT for client-side authentication
+
+Get chart of account to work
+
+CI/CD GiHul >> Azure
+
+Meet and decide on moving the current application into the Blazor template
+
diff --git a/src/AccountGoWeb/.vscode/launch.json b/src/AccountGoWeb/.vscode/launch.json
index 5825c3616..ddd8758f9 100644
--- a/src/AccountGoWeb/.vscode/launch.json
+++ b/src/AccountGoWeb/.vscode/launch.json
@@ -4,6 +4,11 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
+ {
+ "name": ".NET Core Attach",
+ "type": "coreclr",
+ "request": "attach"
+ },
{
"name": ".NET Core Launch (web)",
"type": "coreclr",
diff --git a/src/AccountGoWeb/AccountGoWeb.csproj b/src/AccountGoWeb/AccountGoWeb.csproj
index 0175223c6..1fe33c984 100644
--- a/src/AccountGoWeb/AccountGoWeb.csproj
+++ b/src/AccountGoWeb/AccountGoWeb.csproj
@@ -1,46 +1,37 @@
-
-
+
- net7.0
- true
- AccountGoWeb
- AccountGoWeb
- latest
- 0.0.1-alpha
- Latest
+ net10.0
+ GoodBooks
+ GoodBooks
+ 1.0.0
+ enable
+ enable
+ true
+ aspnet-GoodBooks-21ac3a7f-d42e-4136-9340-b4f6254706df
+
+ true
+ $(NoWarn);NU1701
-
PreserveNewest
-
-
+
+
+
-
-
+
+
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/src/AccountGoWeb/Components/App.razor b/src/AccountGoWeb/Components/App.razor
new file mode 100644
index 000000000..abb7c3dd7
--- /dev/null
+++ b/src/AccountGoWeb/Components/App.razor
@@ -0,0 +1,23 @@
+@*
+
+
+
+
+
+ *@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/AccountGoWeb/Components/Pages/Audit/AuditableEntities.razor b/src/AccountGoWeb/Components/Pages/Audit/AuditableEntities.razor
new file mode 100644
index 000000000..2279f4ceb
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Audit/AuditableEntities.razor
@@ -0,0 +1,221 @@
+@page "/audit/auditable-entities-blazor"
+@namespace AccountGoWeb.Components.Pages.Audit
+@using Dto.Auditing
+@using System.Text.Json
+@inject IHttpClientFactory ClientFactory
+@inject IConfiguration Configuration
+@inject NavigationManager Navigation
+
+Auditable Entities
+
+@if (getError)
+{
+ Unable to get data. Please try again later.
+}
+else if (isLoading)
+{
+
+
+ Loading...
+
+
Loading auditable entities...
+
+}
+else
+{
+
+
+
+
+
+
+
+
+ @if (entities == null || !entities.Any())
+ {
+
+
No auditable entities found.
+
+ }
+ else
+ {
+
+
+
+
+ ID
+ Entity Name
+ Enable Audit
+ Actions
+
+
+
+ @foreach (var entity in entities)
+ {
+ NavigateToAuditableEntity(entity)" style="cursor: pointer;">
+ @entity.Id
+ @entity.EntityName
+
+ @if (entity.EnableAudit)
+ {
+
+ }
+ else
+ {
+
+ }
+
+
+
+ Attributes
+
+ OpenDeleteModal(entity)">
+ Delete
+
+
+
+ }
+
+
+
+ }
+
+
+
+
+}
+
+@* Delete Confirmation Modal *@
+@if (isDeleteModalVisible && selectedEntity != null)
+{
+
+
+
+
+
+
+ Are you sure you want to delete the entity
+ @selectedEntity.EntityName ?
+
+ @if (!string.IsNullOrEmpty(errorMessage))
+ {
+
@errorMessage
+ }
+
+
+
+
+
+
+}
+
+@code {
+ private List entities = new();
+ private AuditableEntity? selectedEntity = null;
+ private bool isDeleteModalVisible = false;
+ private string errorMessage = string.Empty;
+ private bool isLoading = true;
+ private bool getError = false;
+ private string apiUrl = string.Empty;
+
+ protected override async Task OnInitializedAsync()
+ {
+ // Get API URL from configuration (same as Program.cs sets it)
+ apiUrl = Configuration["ApiUrl"]!;
+ await LoadEntitiesFromApi();
+ isLoading = false;
+ }
+
+ private async Task LoadEntitiesFromApi()
+ {
+ try
+ {
+ var client = ClientFactory.CreateClient();
+
+ var response = await client.GetAsync($"{apiUrl}audit/entities");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var jsonString = await response.Content.ReadAsStringAsync();
+ entities = JsonSerializer.Deserialize>(jsonString, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ }) ?? new List();
+ }
+ else
+ {
+ getError = true;
+ }
+ }
+ catch
+ {
+ getError = true;
+ }
+ }
+
+ private void OpenDeleteModal(AuditableEntity entity)
+ {
+ selectedEntity = entity;
+ errorMessage = string.Empty;
+ isDeleteModalVisible = true;
+ }
+
+ private void CloseDeleteModal()
+ {
+ isDeleteModalVisible = false;
+ selectedEntity = null;
+ errorMessage = string.Empty;
+ }
+
+ private void NavigateToAuditableEntity(AuditableEntity entity)
+ {
+ if (entity != null)
+ {
+ Navigation.NavigateTo($"/Audit/GetEntity?id={entity.Id}", forceLoad: true);
+ }
+ }
+
+ private async Task ConfirmDeleteEntity()
+ {
+ if (selectedEntity == null)
+ {
+ errorMessage = "No entity selected.";
+ return;
+ }
+
+ try
+ {
+ var client = ClientFactory.CreateClient();
+
+ var response = await client.DeleteAsync($"{apiUrl}audit/entity/{selectedEntity.Id}");
+
+ if (response.IsSuccessStatusCode)
+ {
+ // Remove from local list
+ entities.Remove(selectedEntity);
+ CloseDeleteModal();
+
+ // Reload to ensure data is fresh
+ await LoadEntitiesFromApi();
+ }
+ else
+ {
+ errorMessage = "Failed to delete entity. Please try again.";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error deleting entity: {ex.Message}";
+ }
+ }
+}
diff --git a/src/AccountGoWeb/Components/Pages/Audit/EntityForm.razor b/src/AccountGoWeb/Components/Pages/Audit/EntityForm.razor
new file mode 100644
index 000000000..b87cb0159
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Audit/EntityForm.razor
@@ -0,0 +1,153 @@
+@page "/audit/entity-form"
+@page "/audit/entity-form/{Id:int}"
+@namespace AccountGoWeb.Components.Pages.Audit
+@using Dto.Auditing
+@using System.Text.Json
+@using System.Text
+@inject IHttpClientFactory ClientFactory
+@inject IConfiguration Configuration
+@inject NavigationManager Navigation
+
+@(Id == 0 ? "Add New" : "Edit") Auditable Entity
+
+@if (isLoading)
+{
+
+}
+else
+{
+
+
+
+
+
+
+
+
+
+
+
+ Entity Name
+
+
+
+
+
+
+
+ @if (!string.IsNullOrEmpty(errorMessage))
+ {
+
@errorMessage
+ }
+
+
+
+
+
+
+
+
+}
+
+@code {
+ [Parameter]
+ public int Id { get; set; } = 0;
+
+ private AuditableEntity entity = new() { EnableAudit = true };
+ private bool isSaving = false;
+ private bool isLoading = false;
+ private string errorMessage = string.Empty;
+ private string apiUrl = string.Empty;
+
+ protected override async Task OnInitializedAsync()
+ {
+ apiUrl = Configuration["ApiUrl"] ?? "http://localhost:8001/api/";
+
+ if (Id > 0)
+ {
+ isLoading = true;
+ await LoadEntity();
+ isLoading = false;
+ }
+ }
+
+ private async Task LoadEntity()
+ {
+ try
+ {
+ var client = ClientFactory.CreateClient();
+ var response = await client.GetAsync($"{apiUrl}audit/entity?id={Id}");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var jsonString = await response.Content.ReadAsStringAsync();
+ entity = JsonSerializer.Deserialize(jsonString, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ }) ?? new AuditableEntity { EnableAudit = true };
+ }
+ else
+ {
+ errorMessage = "Failed to load entity.";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading entity: {ex.Message}";
+ }
+ }
+
+ private async Task HandleSubmit()
+ {
+ isSaving = true;
+ errorMessage = string.Empty;
+
+ try
+ {
+ var client = ClientFactory.CreateClient();
+ var json = JsonSerializer.Serialize(entity);
+ var content = new StringContent(json, Encoding.UTF8, "application/json");
+
+ var response = await client.PostAsync($"{apiUrl}audit/entity", content);
+
+ if (response.IsSuccessStatusCode)
+ {
+ Navigation.NavigateTo("/Audit/GetAuditableEntities", forceLoad: true);
+ }
+ else
+ {
+ errorMessage = "Failed to save entity. Please try again.";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error saving entity: {ex.Message}";
+ }
+ finally
+ {
+ isSaving = false;
+ }
+ }
+}
diff --git a/src/AccountGoWeb/Components/Pages/Contact/Contact.razor b/src/AccountGoWeb/Components/Pages/Contact/Contact.razor
new file mode 100644
index 000000000..a436d3d74
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Contact/Contact.razor
@@ -0,0 +1,230 @@
+@using ContactDto = Dto.Common.Contact
+@inject HttpClient Http
+@inject NavigationManager Navigation
+@rendermode InteractiveServer
+
+@(Id == 0 ? "New" : "Edit") Contact
+
+
+
+
+
+
+ @if (Id != 0 && !isEditMode)
+ {
+
+ Edit
+
+ }
+
+
+
+ @if (isLoading)
+ {
+
+
+
+ Loading...
+
+
Loading contact...
+
+
+ }
+ else if (errorMessage != null)
+ {
+
+ }
+ else
+ {
+
+
+
+
+
+
+
+
+ First Name
+
+
+
+
+
+
+
+
+ Last Name
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @if (isEditMode || Id == 0)
+ {
+
+ Save
+
+ }
+
+ Close
+
+
+
+
+ }
+
+
+@code {
+ [Parameter]
+ public int Id { get; set; } = 0;
+
+ [Parameter]
+ [SupplyParameterFromQuery]
+ public int? PartyId { get; set; }
+
+ [Parameter]
+ [SupplyParameterFromQuery]
+ public int? PartyType { get; set; }
+
+ private ContactDto Model { get; set; } = new();
+
+ private bool isLoading = true;
+ private bool isEditMode = false;
+ private string? errorMessage;
+
+ protected override async Task OnInitializedAsync()
+ {
+ isLoading = true;
+
+ if (Id != 0)
+ {
+ await LoadContact();
+ }
+ else
+ {
+ isEditMode = true;
+ Model.HoldingPartyId = PartyId.GetValueOrDefault();
+ Model.HoldingPartyType = PartyType.GetValueOrDefault();
+ }
+
+ isLoading = false;
+ }
+
+ private async Task LoadContact()
+ {
+ try
+ {
+ string baseApiUrl = Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var apiUrl = $"{baseApiUrl}contact/contact?id={Id}";
+ var response = await Http.GetAsync(apiUrl);
+
+ if (response.IsSuccessStatusCode)
+ {
+ Model = await response.Content.ReadFromJsonAsync() ?? new();
+ }
+ else
+ {
+ errorMessage = $"Failed to load contact. Status: {response.StatusCode}";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading contact: {ex.Message}";
+ }
+ }
+
+ private async Task HandleValidSubmit()
+ {
+ try
+ {
+ string baseApiUrl = Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var apiUrl = baseApiUrl + "contact/savecontact";
+
+ var response = await Http.PostAsJsonAsync(apiUrl, Model);
+
+ if (response.IsSuccessStatusCode)
+ {
+ if (PartyId.HasValue && PartyType.HasValue)
+ {
+ Navigation.NavigateTo($"/contact/contacts?partyId={PartyId}&partyType={PartyType}", forceLoad: true);
+ }
+ else
+ {
+ Navigation.NavigateTo("/sales/customers", forceLoad: true);
+ }
+ }
+ else
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ errorMessage = $"Failed to save contact. Status: {response.StatusCode}. {errorContent}";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error saving contact: {ex.Message}";
+ }
+ }
+
+ private void EnableEditMode()
+ {
+ isEditMode = true;
+ }
+
+ private void NavigateBack()
+ {
+ if (PartyId.HasValue && PartyType.HasValue)
+ {
+ Navigation.NavigateTo($"/contact/contacts?partyId={PartyId}&partyType={PartyType}", forceLoad: true);
+ }
+ else if (PartyId.HasValue)
+ {
+ Navigation.NavigateTo($"/sales/customer/{PartyId}", forceLoad: true);
+ }
+ else
+ {
+ Navigation.NavigateTo("/sales/customers", forceLoad: true);
+ }
+ }
+}
diff --git a/src/AccountGoWeb/Components/Pages/Contact/Contacts.razor b/src/AccountGoWeb/Components/Pages/Contact/Contacts.razor
new file mode 100644
index 000000000..0cd019779
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Contact/Contacts.razor
@@ -0,0 +1,340 @@
+@using ContactDto = Dto.Common.Contact
+@inject HttpClient Http
+@inject NavigationManager Navigation
+@rendermode InteractiveServer
+
+Contacts
+
+
+
+
+
+
+
+ New Contact
+
+ @if (selectedContact != null)
+ {
+
+ View
+
+
+ Set as Primary Contact
+
+ }
+
+ Back to Customers
+
+
+
+
+ @if (successMessage != null)
+ {
+
+
+
+ @successMessage
+ successMessage = null">
+
+
+
+ }
+
+ @if (isLoading)
+ {
+
+
+
+ Loading...
+
+
Loading contacts...
+
+
+ }
+ else if (errorMessage != null)
+ {
+
+ }
+ else if (contacts == null || !contacts.Any())
+ {
+
+
+
+ No contacts found.
+
+
+
+ }
+ else
+ {
+
+
+
+
+
+
+ SortBy(nameof(ContactDto.Id))" style="cursor: pointer;">
+ Id @GetSortIcon(nameof(ContactDto.Id))
+
+ SortBy(nameof(ContactDto.FirstName))" style="cursor: pointer;">
+ First Name @GetSortIcon(nameof(ContactDto.FirstName))
+
+ SortBy(nameof(ContactDto.LastName))" style="cursor: pointer;">
+ Last Name @GetSortIcon(nameof(ContactDto.LastName))
+
+ Primary
+
+
+
+ @foreach (var contact in contacts)
+ {
+ SelectContact(contact)"
+ style="cursor: pointer; @(selectedContact?.Id == contact.Id ? "background-color: #007bff33; border-left: 3px solid #007bff;" : "")">
+
+
+ @contact.Id
+
+
+ @contact.FirstName
+ @contact.LastName
+
+ @if (IsPrimaryContact(contact))
+ {
+
+ Primary
+
+ }
+
+
+ }
+
+
+
+
+
+
+
+
+
Total: @contacts.Count() contact(s)
+
+
+ }
+
+
+@code {
+ [Parameter]
+ [SupplyParameterFromQuery]
+ public int? PartyId { get; set; }
+
+ [Parameter]
+ [SupplyParameterFromQuery]
+ public int? PartyType { get; set; }
+
+ private List? contacts;
+ private ContactDto? selectedContact;
+ private Dto.Sales.Customer? currentCustomer;
+ private bool isLoading = true;
+ private string? errorMessage;
+ private string? successMessage;
+ private string? sortColumn;
+ private bool sortAscending = true;
+
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadCustomer();
+ await LoadContacts();
+ }
+
+ private async Task LoadCustomer()
+ {
+ if (!PartyId.HasValue) return;
+
+ try
+ {
+ string baseApiUrl = Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var customerUrl = $"{baseApiUrl}sales/customer?id={PartyId}";
+ var customerResponse = await Http.GetAsync(customerUrl);
+
+ if (customerResponse.IsSuccessStatusCode)
+ {
+ currentCustomer = await customerResponse.Content.ReadFromJsonAsync();
+ }
+ }
+ catch (Exception)
+ {
+ // Silently fail - customer may not exist yet
+ }
+ }
+
+ private async Task LoadContacts()
+ {
+ isLoading = true;
+ errorMessage = null;
+
+ try
+ {
+ string baseApiUrl = Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var apiUrl = baseApiUrl + $"contact/contacts?partyId={PartyId}&partyType={PartyType}";
+ var response = await Http.GetAsync(apiUrl);
+
+ if (response.IsSuccessStatusCode)
+ {
+ contacts = await response.Content.ReadFromJsonAsync>();
+ }
+ else
+ {
+ errorMessage = $"Failed to load contacts. Status: {response.StatusCode}";
+ }
+ }
+ catch (HttpRequestException ex)
+ {
+ errorMessage = $"Network error: {ex.Message}. Please ensure the API is running on port 8001.";
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading contacts: {ex.Message}";
+ }
+ finally
+ {
+ isLoading = false;
+ }
+ }
+
+ private void SelectContact(ContactDto contact)
+ {
+ selectedContact = contact;
+ }
+
+ private async Task SetAsPrimaryContact()
+ {
+ if (selectedContact == null || !PartyId.HasValue) return;
+
+ try
+ {
+ string baseApiUrl = Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+
+ // Load the customer
+ var customerUrl = $"{baseApiUrl}sales/customer?id={PartyId}";
+ var customerResponse = await Http.GetAsync(customerUrl);
+
+ if (customerResponse.IsSuccessStatusCode)
+ {
+ var customer = await customerResponse.Content.ReadFromJsonAsync();
+ if (customer != null)
+ {
+ // Update customer's primary contact data
+ if (customer.PrimaryContact == null)
+ {
+ customer.PrimaryContact = new ContactDto();
+ }
+ customer.PrimaryContact.FirstName = selectedContact.FirstName;
+ customer.PrimaryContact.LastName = selectedContact.LastName;
+ customer.PrimaryContact.Party = selectedContact.Party;
+
+ // Save the customer
+ var saveUrl = baseApiUrl + "sales/savecustomer";
+ var saveResponse = await Http.PostAsJsonAsync(saveUrl, customer);
+
+ if (saveResponse.IsSuccessStatusCode)
+ {
+ successMessage = $"Set {selectedContact.FirstName} {selectedContact.LastName} as primary contact.";
+ currentCustomer = customer;
+ StateHasChanged();
+ }
+ else
+ {
+ errorMessage = $"Failed to update primary contact. Status: {saveResponse.StatusCode}";
+ }
+ }
+ }
+ else
+ {
+ errorMessage = $"Failed to load customer. Status: {customerResponse.StatusCode}";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error setting primary contact: {ex.Message}";
+ }
+ }
+
+ private void NavigateToViewContact()
+ {
+ if (selectedContact != null)
+ {
+ Navigation.NavigateTo($"/contact/contact/{selectedContact.Id}?partyId={selectedContact.HoldingPartyId}&partyType={selectedContact.HoldingPartyType}", forceLoad: true);
+ }
+ }
+
+ private void NavigateToNewContact()
+ {
+ if (PartyId.HasValue && PartyType.HasValue)
+ {
+ Navigation.NavigateTo($"/contact/contact?partyId={PartyId}&partyType={PartyType}", forceLoad: true);
+ }
+ else
+ {
+ Navigation.NavigateTo("/contact/contact", forceLoad: true);
+ }
+ }
+
+ private void NavigateToCustomers()
+ {
+ if (PartyId.HasValue)
+ {
+ Navigation.NavigateTo($"/sales/customer/{PartyId}", forceLoad: true);
+ }
+ else
+ {
+ Navigation.NavigateTo("/sales/customers", forceLoad: true);
+ }
+ }
+
+ private void SortBy(string column)
+ {
+ if (contacts == null) return;
+
+ if (sortColumn == column)
+ {
+ sortAscending = !sortAscending;
+ }
+ else
+ {
+ sortColumn = column;
+ sortAscending = true;
+ }
+
+ contacts = column switch
+ {
+ nameof(ContactDto.Id) => sortAscending
+ ? contacts.OrderBy(c => c.Id).ToList()
+ : contacts.OrderByDescending(c => c.Id).ToList(),
+ nameof(ContactDto.FirstName) => sortAscending
+ ? contacts.OrderBy(c => c.FirstName).ToList()
+ : contacts.OrderByDescending(c => c.FirstName).ToList(),
+ nameof(ContactDto.LastName) => sortAscending
+ ? contacts.OrderBy(c => c.LastName).ToList()
+ : contacts.OrderByDescending(c => c.LastName).ToList(),
+ _ => contacts
+ };
+ }
+
+ private string GetSortIcon(string column)
+ {
+ if (sortColumn != column) return "";
+ return sortAscending ? "▲" : "▼";
+ }
+
+ private bool IsPrimaryContact(ContactDto contact)
+ {
+ if (currentCustomer?.PrimaryContact == null) return false;
+ return currentCustomer.PrimaryContact.FirstName == contact.FirstName &&
+ currentCustomer.PrimaryContact.LastName == contact.LastName;
+ }
+}
diff --git a/src/AccountGoWeb/Components/Pages/Counter.razor b/src/AccountGoWeb/Components/Pages/Counter.razor
new file mode 100644
index 000000000..0d9d43ad4
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Counter.razor
@@ -0,0 +1,19 @@
+@page "/counter"
+@rendermode InteractiveServer
+
+Counter
+
+Counter
+
+Current count: @currentCount
+
+Click me
+
+@code {
+ private int currentCount = 0;
+
+ private void IncrementCount()
+ {
+ currentCount++;
+ }
+}
diff --git a/src/AccountGoWeb/Components/Pages/Donations/AddDonationInvoice.razor b/src/AccountGoWeb/Components/Pages/Donations/AddDonationInvoice.razor
new file mode 100644
index 000000000..127270a7b
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Donations/AddDonationInvoice.razor
@@ -0,0 +1,362 @@
+@rendermode InteractiveServer
+@using AspNetCoreGeneratedDocument
+@using Dto.Donations
+@using Dto.Sales
+@using Dto.Inventory
+@using System.Text.Json
+@inject HttpClient Http
+@inject IConfiguration Configuration
+@inject NavigationManager NavigationManager
+
+Add Donation Invoice
+
+@if (isLoading)
+{
+
+}
+else if (invoice != null)
+{
+
+
+
+
+
+ @if (!string.IsNullOrEmpty(errorMessage))
+ {
+
+ @errorMessage
+ errorMessage = null">
+
+ }
+
+ @if (!string.IsNullOrEmpty(successMessage))
+ {
+
+ @successMessage
+ successMessage = null">
+
+ }
+
+
+
+
Donor
+
+
+ -- Select Donor --
+ @foreach (var customer in customers)
+ {
+ @customer.Name
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Options
+
+
+
+ Tax Receipt Issued
+
+
+
+ Posted
+
+
+
+
+
+
+
+
+
+ Item
+ Quantity
+ Amount
+ Unit
+ Notes
+
+
+
+
+ @for (int i = 0; i < (invoice.DonationInvoiceLines?.Count ?? 0); i++)
+ {
+ var index = i;
+ var line = invoice.DonationInvoiceLines![index];
+
+
+
+ -- Select Item --
+ @foreach (var item in items)
+ {
+ @item.Description
+ }
+
+
+
+
+
+
+
+
+
+
+ -- Select --
+ @foreach (var measurement in measurements)
+ {
+ @measurement.Description
+ }
+
+
+
+
+
+
+ @if (invoice.DonationInvoiceLines.Count > 1)
+ {
+ RemoveLine(index)">
+
+
+ }
+
+
+ }
+
+
+
+ Add Row
+
+
+
+
+
+
+
+
+
+
+
+ @if (isSaving)
+ {
+
+ }
+ Save
+
+
+ Cancel
+
+
+
+
+}
+
+@code {
+ [Parameter]
+ public int? Id { get; set; }
+
+ private DonationInvoice? invoice;
+ private List customers = new();
+ private List- items = new();
+ private List
measurements = new();
+ private bool isLoading = true;
+ private bool isSaving = false;
+ private string? errorMessage;
+ private string? successMessage;
+
+ public class Measurement
+ {
+ public int Id { get; set; }
+ public string? Code { get; set; }
+ public string? Description { get; set; }
+ }
+
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadData();
+ }
+
+ private async Task LoadData()
+ {
+ isLoading = true;
+ try
+ {
+ var apiUrl = Configuration["ApiUrl"];
+
+ // Load customers
+ var customersResponse = await Http.GetAsync($"{apiUrl}sales/customers");
+ if (customersResponse.IsSuccessStatusCode)
+ {
+ var customersJson = await customersResponse.Content.ReadAsStringAsync();
+ customers = JsonSerializer.Deserialize>(customersJson,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new();
+ }
+
+ // Load items
+ var itemsResponse = await Http.GetAsync($"{apiUrl}inventory/items");
+ if (itemsResponse.IsSuccessStatusCode)
+ {
+ var itemsJson = await itemsResponse.Content.ReadAsStringAsync();
+ items = JsonSerializer.Deserialize>(itemsJson,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new();
+ }
+
+ // Load measurements
+ var measurementsResponse = await Http.GetAsync($"{apiUrl}common/measurements");
+ if (measurementsResponse.IsSuccessStatusCode)
+ {
+ var measurementsJson = await measurementsResponse.Content.ReadAsStringAsync();
+ measurements = JsonSerializer.Deserialize>(measurementsJson,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new();
+ }
+
+ // Initialize or load invoice
+ if (Id.HasValue && Id.Value > 0)
+ {
+ var invoiceResponse = await Http.GetAsync($"{apiUrl}Donations/DonationInvoice?id={Id.Value}");
+ if (invoiceResponse.IsSuccessStatusCode)
+ {
+ var invoiceJson = await invoiceResponse.Content.ReadAsStringAsync();
+ invoice = JsonSerializer.Deserialize(invoiceJson,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ }
+ else
+ {
+ errorMessage = $"Failed to load existing invoice (Status: {invoiceResponse.StatusCode}). Loading empty form.";
+ ResetForm();
+ }
+ }
+ else
+ {
+ ResetForm();
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading data: {ex.Message}";
+ }
+ finally
+ {
+ isLoading = false;
+ }
+ }
+
+ private void AddLine()
+ {
+ invoice?.DonationInvoiceLines?.Add(new DonationInvoiceLine
+ {
+ Amount = 0,
+ Quantity = 1,
+ ItemId = items.FirstOrDefault()?.Id ?? 0,
+ MeasurementId = measurements.FirstOrDefault()?.Id ?? 0
+ });
+ }
+
+ private void RemoveLine(int index)
+ {
+ if (invoice?.DonationInvoiceLines != null && invoice.DonationInvoiceLines.Count > 1)
+ {
+ invoice.DonationInvoiceLines.RemoveAt(index);
+ }
+ }
+
+ private async Task SaveInvoice()
+ {
+ if (invoice == null) return;
+
+ isSaving = true;
+ errorMessage = null;
+ successMessage = null;
+
+ try
+ {
+ var apiUrl = Configuration["ApiUrl"];
+ var json = JsonSerializer.Serialize(invoice);
+ var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
+
+ var endpoint = Id.HasValue && Id.Value > 0 ? "Donations/UpdateDonationInvoice" : "Donations/CreateDonationInvoice";
+ var response = await Http.PostAsync($"{apiUrl}{endpoint}", content);
+
+ if (response.IsSuccessStatusCode)
+ {
+ successMessage = $"Donation invoice '{invoice.No}' saved successfully!";
+ if (!Id.HasValue || Id.Value == 0)
+ {
+ ResetForm();
+ }
+
+ // Auto-navigate after 2 seconds
+ await Task.Delay(2000);
+ NavigationManager.NavigateTo("/donations/donationinvoices", forceLoad: true);
+ }
+ else
+ {
+ var error = await response.Content.ReadAsStringAsync();
+ errorMessage = $"Failed to save: {response.StatusCode} - {error}";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error saving invoice: {ex.Message}";
+ }
+ finally
+ {
+ isSaving = false;
+ }
+ }
+ private void ResetForm()
+ {
+ invoice = new DonationInvoice
+ {
+ No = new Random().Next(1, 99999).ToString(),
+ DonationDate = DateTime.Now,
+ DonationInvoiceLines = new List
+{
+new DonationInvoiceLine
+{
+Amount = 0,
+Quantity = 1,
+ItemId = items.FirstOrDefault()?.Id ?? 0,
+MeasurementId = measurements.FirstOrDefault()?.Id ?? 0
+}
+}
+ };
+ }
+
+ private void Cancel()
+ {
+ NavigationManager.NavigateTo("/donations/donationinvoices", forceLoad: true);
+ }
+}
diff --git a/src/AccountGoWeb/Components/Pages/Donations/DonationInvoices.razor b/src/AccountGoWeb/Components/Pages/Donations/DonationInvoices.razor
new file mode 100644
index 000000000..b8cf7d36d
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Donations/DonationInvoices.razor
@@ -0,0 +1,332 @@
+@rendermode InteractiveServer
+@using AspNetCoreGeneratedDocument
+@using Dto.Donations
+@inject HttpClient Http
+@inject IConfiguration Configuration
+@inject NavigationManager NavigationManager
+@inject IJSRuntime JSRuntime
+
+
+
+Donation Invoices
+
+
+
+@if (isLoading)
+{
+
+}
+else if (donationInvoices != null && donationInvoices.Any())
+{
+
+}
+else
+{
+
+
+
+
+ Invoice No
+ Donor Name
+ Date
+ Amount
+ Purpose
+ Reference No
+ Tax Receipt No
+ Tax Receipt
+ Status
+
+
+
+ @foreach (var invoice in donationInvoices)
+ {
+ SelectInvoice(invoice)">
+ @invoice.No
+ @invoice.DonorName
+ @invoice.DonationDate.ToString("MMM dd, yyyy")
+ $@invoice.Amount.ToString("F2")
+ @invoice.Purpose
+ @invoice.ReferenceNo
+ @invoice.TaxReceiptNo
+
+ @if (invoice.IsTaxReceiptIssued)
+ {
+ Issued
+ }
+ else
+ {
+ Pending
+ }
+
+
+ @if (invoice.Posted)
+ {
+ Posted
+ }
+ else
+ {
+ Draft
+ }
+
+
+ }
+
+
+
+}
+
+@* Delete Confirmation Modal *@
+@if (showDeleteModal)
+{
+
+
+
+
+
+
Are you sure you want to delete this donation invoice?
+ @if (selectedInvoice != null)
+ {
+
+
Invoice No: @selectedInvoice.No
+
Donor: @selectedInvoice.DonorName
+
Amount: @selectedInvoice.Amount.ToString("C2")
+
+ }
+
+
+
+
+
+}
+
+@code {
+ private List? donationInvoices;
+ private DonationInvoice? selectedInvoice;
+ private bool isLoading = true;
+ private bool showDeleteModal = false;
+ private bool HasSelection => selectedInvoice != null;
+
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadDonationInvoices();
+ }
+
+ private async Task LoadDonationInvoices()
+ {
+ isLoading = true;
+ try
+ {
+ var apiUrl = Configuration["ApiUrl"];
+ var response = await Http.GetAsync($"{apiUrl}donations/donationinvoices");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var responseJson = await response.Content.ReadAsStringAsync();
+ donationInvoices = System.Text.Json.JsonSerializer.Deserialize>(
+ responseJson,
+ new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }
+ );
+ }
+ else
+ {
+ donationInvoices = new List();
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error loading donation invoices: {ex.Message}");
+ donationInvoices = new List();
+ }
+ finally
+ {
+ isLoading = false;
+ }
+ }
+
+ private void SelectInvoice(DonationInvoice invoice)
+ {
+ selectedInvoice = invoice;
+ }
+
+ private void NavigateToDonationInvoice(DonationInvoice invoice)
+ {
+ SelectInvoice(invoice);
+ if (selectedInvoice != null)
+ {
+ NavigationManager.NavigateTo($"/donations/donationinvoice/{selectedInvoice.Id}", forceLoad: true);
+ }
+ }
+
+ private void EditInvoice()
+ {
+ if (selectedInvoice != null)
+ {
+ NavigationManager.NavigateTo($"/donations/donationinvoice/{selectedInvoice.Id}");
+ }
+ }
+
+ private void ShowDeleteModal()
+ {
+ if (selectedInvoice != null)
+ {
+ showDeleteModal = true;
+ }
+ }
+
+ private void CloseDeleteModal()
+ {
+ showDeleteModal = false;
+ }
+
+ private async Task DeleteInvoice()
+ {
+ if (selectedInvoice != null)
+ {
+ try
+ {
+ NavigationManager.NavigateTo($"/donations/deletedonationinvoice/{selectedInvoice.Id}");
+ showDeleteModal = false;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error deleting invoice: {ex.Message}");
+ }
+ }
+ }
+}
diff --git a/src/AccountGoWeb/Components/Pages/Financial/AddJournalEntry.razor b/src/AccountGoWeb/Components/Pages/Financial/AddJournalEntry.razor
new file mode 100644
index 000000000..d97f2d52e
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Financial/AddJournalEntry.razor
@@ -0,0 +1,347 @@
+@using System.Net.Http.Json
+@using Dto.Financial
+@inject IHttpClientFactory HttpFactory
+@inject IConfiguration Config
+@using JournalEntryDto = Dto.Financial.JournalEntry
+@using JournalEntryLineDto = Dto.Financial.JournalEntryLine
+
+
+@* Add Journal Entry *@
+
+@if (!string.IsNullOrEmpty(SuccessMessage))
+{
+ @SuccessMessage
+}
+
+@if (!string.IsNullOrEmpty(ErrorMessage))
+{
+ @ErrorMessage
+}
+
+
+
+
+
+
+
+
+
Header
+
+
+ Date
+
+
+
+
+ Reference No
+
+
+
+
+ Voucher Type
+
+ -- select type --
+ Opening Balances
+ Closing Entries
+ Adjustment Entries
+ Correction Entries
+ Transfer Entries
+
+
+
+
+ Memo
+
+
+
+
+
+
+
+
+ Lines
+
+ Add Line
+
+
+
+
+ @if (Accounts == null || Accounts.Count == 0)
+ {
+
+ Accounts are not loaded yet. You won’t be able to save until accounts are available.
+
+ }
+
+
+
+
+ Account
+ Dr/Cr
+ Amount
+ Memo
+
+
+
+
+ @if (Entry.JournalEntryLines != null)
+ {
+ @foreach (var line in Entry.JournalEntryLines)
+ {
+ var currentLine = line;
+
+
+ @if (Accounts != null && Accounts.Count > 0)
+ {
+
+ -- select account --
+ @foreach (var acct in Accounts)
+ {
+
+ @acct.AccountCode - @acct.AccountName
+
+ }
+
+ }
+ else
+ {
+ No accounts
+ }
+
+
+
+
+ Debit
+ Credit
+
+
+
+
+
+
+
+
+
+
+
+
+
+ RemoveLine(currentLine)">
+
+
+
+
+ }
+ }
+
+
+
+ Total Debit
+ @TotalDebit.ToString("0.00")
+
+
+
+ Total Credit
+ @TotalCredit.ToString("0.00")
+
+
+
+
+
+
+
+
+
+
+ @if (IsSaving)
+ {
+ Saving...
+ }
+ else
+ {
+ Save
+ }
+
+
+
+
+
+@code {
+ private JournalEntryDto Entry = new();
+
+ private bool IsSaving;
+ private string? ErrorMessage;
+ private string? SuccessMessage;
+
+ private List AccountTree = new();
+ private List Accounts = new();
+
+ private decimal TotalDebit =>
+ Entry.JournalEntryLines?
+ .Where(l => l.DrCr == 1) // 1 = Debit
+ .Sum(l => l.Amount ?? 0) ?? 0;
+
+ private decimal TotalCredit =>
+ Entry.JournalEntryLines?
+ .Where(l => l.DrCr == 2) // 2 = Credit
+ .Sum(l => l.Amount ?? 0) ?? 0;
+
+ protected override async Task OnInitializedAsync()
+ {
+ // remove this if BaseDto doesn't have Id
+ // Entry.Id = 0;
+
+ Entry.JournalDate = DateTime.Today;
+ Entry.Posted = false;
+ Entry.VoucherType ??= 1;
+ Entry.JournalEntryLines ??= new List();
+
+ AddLine();
+ await LoadAccountsAsync();
+ }
+
+ private async Task LoadAccountsAsync()
+ {
+ try
+ {
+ var apiBaseUrl = Config["ApiUrl"];
+ if (string.IsNullOrWhiteSpace(apiBaseUrl))
+ {
+ ErrorMessage = "ApiUrl is not configured.";
+ return;
+ }
+
+ var client = HttpFactory.CreateClient();
+ var url = $"{apiBaseUrl}financials/accounts";
+
+ var tree = await client.GetFromJsonAsync>(url);
+
+ if (tree != null)
+ {
+ AccountTree = tree;
+ Accounts = FlattenAccounts(AccountTree)
+ .OrderBy(a => a.AccountCode)
+ .ToList();
+ }
+ else
+ {
+ ErrorMessage = "Could not load accounts (empty response).";
+ }
+ }
+ catch (Exception ex)
+ {
+ ErrorMessage = $"Error loading accounts: {ex.Message}";
+ }
+ }
+
+ private List FlattenAccounts(IEnumerable nodes)
+ {
+ var list = new List();
+
+ void Walk(IEnumerable items)
+ {
+ foreach (var a in items)
+ {
+ list.Add(a);
+ if (a.ChildAccounts != null && a.ChildAccounts.Count > 0)
+ Walk(a.ChildAccounts);
+ }
+ }
+
+ Walk(nodes);
+ return list;
+ }
+
+ private void AddLine()
+ {
+ Entry.JournalEntryLines!.Add(new JournalEntryLineDto
+ {
+
+ DrCr = 1,
+ Amount = 0
+ });
+ }
+
+ private void RemoveLine(JournalEntryLineDto line)
+ {
+ Entry.JournalEntryLines!.Remove(line);
+ }
+
+ private async Task HandleValidSubmit()
+ {
+ ErrorMessage = null;
+ SuccessMessage = null;
+ IsSaving = true;
+
+ @* if (TotalDebit != TotalCredit)
+ {
+ ErrorMessage = "Debits and credits are not equal.";
+ IsSaving = false;
+ return;
+ } *@
+
+ if (Entry.JournalEntryLines == null || Entry.JournalEntryLines.Any(l => l.AccountId == null))
+ {
+ ErrorMessage = "All lines must have an account selected.";
+ IsSaving = false;
+ return;
+ }
+
+ try
+ {
+ var apiBaseUrl = Config["ApiUrl"];
+ if (string.IsNullOrWhiteSpace(apiBaseUrl))
+ {
+ ErrorMessage = "ApiUrl is not configured.";
+ return;
+ }
+
+ var client = HttpFactory.CreateClient();
+ var url = $"{apiBaseUrl}financials/SaveJournalEntry";
+
+ var response = await client.PostAsJsonAsync(url, Entry);
+
+ if (response.IsSuccessStatusCode)
+ {
+ SuccessMessage = "Journal entry saved successfully.";
+
+ // reset using DTO alias
+ Entry = new JournalEntryDto
+ {
+ JournalDate = DateTime.Today,
+ VoucherType = 1,
+ Posted = false,
+ JournalEntryLines = new List()
+ };
+ AddLine();
+ }
+ else
+ {
+ try
+ {
+ var errors = await response.Content.ReadFromJsonAsync();
+ ErrorMessage = errors != null && errors.Length > 0
+ ? string.Join("; ", errors)
+ : $"Error saving (HTTP {(int)response.StatusCode})";
+ }
+ catch
+ {
+ ErrorMessage = $"Error saving (HTTP {(int)response.StatusCode})";
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ ErrorMessage = $"Unexpected error: {ex.Message}";
+ }
+ finally
+ {
+ IsSaving = false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/AccountGoWeb/Components/Pages/Financial/ChartOfAccounts.razor b/src/AccountGoWeb/Components/Pages/Financial/ChartOfAccounts.razor
new file mode 100644
index 000000000..10ce33e3f
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Financial/ChartOfAccounts.razor
@@ -0,0 +1,828 @@
+@page "/financials/chart-of-accounts"
+@using System.Text.Json
+@using System.Text.Json.Serialization
+@using System.Net.Http.Json
+@using LibraryGDB.Models.Financial
+@using Microsoft.JSInterop
+@using Microsoft.Net.Http.Headers
+@using Microsoft.AspNetCore.Components
+@inject IHttpClientFactory ClientFactory
+@inject Microsoft.JSInterop.IJSRuntime JSRuntime
+
+Chart of Accounts
+
+@if (getError || accounts is null)
+{
+ Unable to get data. Please try again later.
+}
+else if (isLoading)
+{
+ Loading accounts...
+}
+else
+{
+ @*
+ @foreach (var item in accounts)
+ {
+ @item.AccountName
+ }
+ *@
+
+
+
OpenAddModal()">Add Account
+
+
+
+
+
+ Code
+ Name
+ Balance
+ Debit
+ Credit
+ Actions
+
+
+
+ @{ var sortedTopLevelAccounts = BuildDisplayHierarchy(accounts); }
+ @for (int accountIdx = 0; accountIdx < sortedTopLevelAccounts.Count; ++accountIdx)
+ {
+ var account = sortedTopLevelAccounts[accountIdx];
+ var rowKey = accountIdx.ToString();
+ var hasChildren = account.ChildAccounts != null && account.ChildAccounts.Count > 0;
+
+
+
+ @if (hasChildren)
+ {
+ ToggleRow(rowKey)"
+ @onclick:stopPropagation="true" title="Toggle child accounts">
+ @(IsExpanded(rowKey) ? "▾" : "▸")
+
+ }
+
+
+ @account.AccountCode
+ @account.AccountName
+ @account.TotalBalance
+ @account.TotalDebitBalance
+ @account.TotalCreditBalance
+
+
+
+ OpenAddModal(account)"
+ @onclick:stopPropagation="true">
+ Add Account
+
+
+ OpenEditModal(account)"
+ @onclick:stopPropagation="true">
+ Edit
+
+
+ OpenDeleteModal(account)"
+ @onclick:stopPropagation="true">
+ Delete
+
+
+
+
+
+ @if (hasChildren && IsExpanded(rowKey))
+ {
+ @RenderNestedAccounts(account.ChildAccounts!, $"{accountIdx}", 1)
+ }
+ }
+
+
+
+
+}
+@if (isAddModalVisible || isEditModalVisible)
+{
+
+
+
+
+
+
+ Account Code
+ selectedAccount!.AccountCode = e.Value?.ToString() ?? string.Empty"
+ disabled="@isEditModalVisible" />
+
+
+ Account Name
+ selectedAccount!.AccountName = e.Value?.ToString() ?? string.Empty" />
+
+ @if (!string.IsNullOrEmpty(errorMessage))
+ {
+
@errorMessage
+ }
+
+
+
+
+
+}
+
+@if (isDeleteModalVisible)
+{
+
+
+
+
+
+
+ Are you sure you want to delete the account
+ @selectedAccount?.AccountName
+ with code @selectedAccount?.AccountCode ?
+
+ @if (!string.IsNullOrEmpty(errorMessage))
+ {
+
@errorMessage
+ }
+
+
+
+
+
+}
+
+@code {
+ private List accounts = new();
+ private AccountViewModel? selectedAccount = null;
+ private AccountViewModel? parentAccount = null; // Track parent account when adding sub-account
+ private bool isAddModalVisible = false;
+ private bool isEditModalVisible = false;
+ private bool isDeleteModalVisible = false;
+ private string errorMessage = string.Empty;
+ private bool isLoading = true;
+ private bool getError = false;
+ private HashSet expandedRows = new();
+
+ private bool IsExpanded(string rowKey) => expandedRows.Contains(rowKey);
+
+ private void ToggleRow(string rowKey)
+ {
+ if (expandedRows.Contains(rowKey))
+ {
+ CollapseRowAndChildren(rowKey);
+ }
+ else
+ {
+ expandedRows.Add(rowKey);
+ }
+ }
+
+ private void CollapseRowAndChildren(string rowKey)
+ {
+ expandedRows.RemoveWhere(x => x == rowKey || x.StartsWith(rowKey + "-"));
+ }
+
+ private IEnumerable SortAccounts(IEnumerable items)
+ {
+ return items.OrderBy(account => account, AccountCodeSortComparer.Instance);
+ }
+
+ // Build a display hierarchy by re-parenting accounts under their major sections based on account codes.
+ private List BuildDisplayHierarchy(IEnumerable sourceAccounts)
+ {
+ var workingRoots = sourceAccounts
+ .Select(CloneAccountTree)
+ .ToList();
+
+ var majorSections = workingRoots
+ .Where(account => IsMajorSectionCode(account.AccountCode))
+ .ToDictionary(account => account.AccountCode, StringComparer.OrdinalIgnoreCase);
+
+ var rootsToRehome = new List();
+ var sectionCandidates = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var account in workingRoots)
+ {
+ if (IsMajorSectionCode(account.AccountCode) || IsIncomeSummaryCode(account.AccountCode))
+ {
+ continue;
+ }
+
+ if (!TryGetMajorSectionCode(account.AccountCode, out var targetSectionCode))
+ {
+ continue;
+ }
+
+ if (!majorSections.ContainsKey(targetSectionCode))
+ {
+ continue;
+ }
+
+ if (!sectionCandidates.TryGetValue(targetSectionCode, out var candidates))
+ {
+ candidates = new List();
+ sectionCandidates[targetSectionCode] = candidates;
+ }
+
+ candidates.Add(account);
+ }
+
+ foreach (var sectionCandidateGroup in sectionCandidates)
+ {
+ if (!majorSections.TryGetValue(sectionCandidateGroup.Key, out var section))
+ {
+ continue;
+ }
+
+ var orderedCandidates = SortAccounts(sectionCandidateGroup.Value).ToList();
+ foreach (var account in orderedCandidates)
+ {
+ var displayParent = FindBestDisplayParent(section, account.AccountCode) ?? section;
+ displayParent.ChildAccounts.Add(account);
+ rootsToRehome.Add(account);
+ }
+ }
+
+ if (rootsToRehome.Count > 0)
+ {
+ workingRoots.RemoveAll(account => rootsToRehome.Contains(account));
+ }
+
+ SortTree(workingRoots);
+ return workingRoots;
+ }
+
+ // Find the best parent node by checking candidate parent codes derived from the account code.
+ private AccountViewModel? FindBestDisplayParent(AccountViewModel sectionRoot, string? accountCode)
+ {
+ foreach (var candidateParentCode in GetCandidateParentCodes(accountCode))
+ {
+ var candidate = FindDeepestNodeByCode(sectionRoot, candidateParentCode, 0, out _);
+ if (candidate != null)
+ {
+ return candidate;
+ }
+ }
+
+ return null;
+ }
+
+ // Generate candidate parent codes by rolling up the account code to higher levels
+ private IEnumerable GetCandidateParentCodes(string? accountCode)
+ {
+ if (!int.TryParse(accountCode, out var numericCode))
+ {
+ return Enumerable.Empty();
+ }
+
+ if (!TryGetMajorSectionCode(accountCode, out var sectionCode) || !int.TryParse(sectionCode, out var sectionNumericCode))
+ {
+ return Enumerable.Empty();
+ }
+
+ var candidates = new List();
+ var seen = new HashSet();
+
+ for (var place = 10; place <= 10000; place *= 10)
+ {
+ var rolledUpCode = (numericCode / place) * place;
+
+ if (rolledUpCode == numericCode || rolledUpCode < sectionNumericCode)
+ {
+ continue;
+ }
+
+ if (seen.Add(rolledUpCode))
+ {
+ candidates.Add(rolledUpCode);
+ }
+ }
+
+ candidates.Sort((x, y) => y.CompareTo(x));
+ return candidates.Select(code => code.ToString());
+ }
+
+ // Recursively search for the deepest node matching the target account code
+ private AccountViewModel? FindDeepestNodeByCode(AccountViewModel node, string targetCode, int depth, out int bestDepth)
+ {
+ AccountViewModel? bestMatch = null;
+ bestDepth = -1;
+
+ if (string.Equals(node.AccountCode, targetCode, StringComparison.OrdinalIgnoreCase))
+ {
+ bestMatch = node;
+ bestDepth = depth;
+ }
+
+ if (node.ChildAccounts == null || node.ChildAccounts.Count == 0)
+ {
+ return bestMatch;
+ }
+
+ foreach (var child in node.ChildAccounts)
+ {
+ var childMatch = FindDeepestNodeByCode(child, targetCode, depth + 1, out var childDepth);
+ if (childMatch != null && childDepth > bestDepth)
+ {
+ bestMatch = childMatch;
+ bestDepth = childDepth;
+ }
+ }
+
+ return bestMatch;
+ }
+
+ // Recursively clone accounts to avoid modifying original data when building display hierarchy
+ private AccountViewModel CloneAccountTree(AccountViewModel account)
+ {
+ return new AccountViewModel
+ {
+ Id = account.Id,
+ ParentAccountId = account.ParentAccountId,
+ AccountCode = account.AccountCode,
+ AccountName = account.AccountName,
+ TotalBalance = account.TotalBalance,
+ TotalDebitBalance = account.TotalDebitBalance,
+ TotalCreditBalance = account.TotalCreditBalance,
+ ChildAccounts = account.ChildAccounts?
+ .Select(CloneAccountTree)
+ .ToList() ?? new List()
+ };
+ }
+
+ // Recursively sort accounts and their children by account code
+ private void SortTree(List nodes)
+ {
+ var orderedNodes = SortAccounts(nodes).ToList();
+ nodes.Clear();
+ nodes.AddRange(orderedNodes);
+
+ foreach (var node in nodes)
+ {
+ if (node.ChildAccounts != null && node.ChildAccounts.Count > 0)
+ {
+ SortTree(node.ChildAccounts);
+ }
+ }
+ }
+
+ // Determine if the account code is a major section code (10000, 20000, 30000, 40000, 50000).
+ private bool IsMajorSectionCode(string? accountCode)
+ {
+ return string.Equals(accountCode, "10000", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(accountCode, "20000", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(accountCode, "30000", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(accountCode, "40000", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(accountCode, "50000", StringComparison.OrdinalIgnoreCase);
+ }
+
+ // Determine if the account code is the special "Income Summary" code (99999).
+ private bool IsIncomeSummaryCode(string? accountCode)
+ {
+ return string.Equals(accountCode, "99999", StringComparison.OrdinalIgnoreCase);
+ }
+
+ // Determine the major section code based on the account code.
+ private bool TryGetMajorSectionCode(string? accountCode, out string sectionCode)
+ {
+ sectionCode = string.Empty;
+
+ if (!int.TryParse(accountCode, out var numericCode))
+ {
+ return false;
+ }
+
+ if (numericCode >= 10000 && numericCode <= 19999)
+ {
+ sectionCode = "10000";
+ return true;
+ }
+
+ if (numericCode >= 20000 && numericCode <= 29999)
+ {
+ sectionCode = "20000";
+ return true;
+ }
+
+ if (numericCode >= 30000 && numericCode <= 39999)
+ {
+ sectionCode = "30000";
+ return true;
+ }
+
+ if (numericCode >= 40000 && numericCode <= 49999)
+ {
+ sectionCode = "40000";
+ return true;
+ }
+
+ if (numericCode >= 50000 && numericCode <= 59999)
+ {
+ sectionCode = "50000";
+ return true;
+ }
+
+ return false;
+ }
+
+ private sealed class AccountCodeSortComparer : IComparer
+ {
+ public static AccountCodeSortComparer Instance { get; } = new();
+
+ public int Compare(AccountViewModel? x, AccountViewModel? y)
+ {
+ var xCode = x?.AccountCode ?? string.Empty;
+ var yCode = y?.AccountCode ?? string.Empty;
+
+ var xIsNumeric = long.TryParse(xCode, out var xNumber);
+ var yIsNumeric = long.TryParse(yCode, out var yNumber);
+
+ if (xIsNumeric && yIsNumeric)
+ {
+ var numericCompare = xNumber.CompareTo(yNumber);
+ if (numericCompare != 0)
+ {
+ return numericCompare;
+ }
+ }
+
+ return string.Compare(xCode, yCode, StringComparison.OrdinalIgnoreCase);
+ }
+ }
+
+ // Render nested accounts as rows in the same table body.
+ private RenderFragment RenderNestedAccounts(List nestedAccounts, string parentPath, int level)
+ {
+ return builder =>
+ {
+ int sequence = 0;
+ var sortedNestedAccounts = SortAccounts(nestedAccounts).ToList();
+
+ for (int idx = 0; idx < sortedNestedAccounts.Count; idx++)
+ {
+ var account = sortedNestedAccounts[idx];
+ var accountKey = account.Id != 0 ? account.Id.ToString() : account.AccountCode;
+ var accountPath = $"{parentPath}-{idx}";
+ var hasChildren = account.ChildAccounts != null && account.ChildAccounts.Count > 0;
+
+ builder.OpenElement(sequence++, "tr");
+ builder.SetKey($"{accountPath}-{accountKey}");
+
+ builder.OpenElement(sequence++, "td");
+ builder.AddAttribute(sequence++, "class", "text-center align-middle");
+ if (hasChildren)
+ {
+ builder.OpenElement(sequence++, "button");
+ builder.AddAttribute(sequence++, "type", "button");
+ builder.AddAttribute(sequence++, "class", "btn btn-outline-secondary coa-toggle-btn");
+ builder.AddAttribute(sequence++, "onclick", EventCallback.Factory.Create(this, () => ToggleRow(accountPath)));
+ builder.AddAttribute(sequence++, "onclick:stopPropagation", "true");
+ builder.AddAttribute(sequence++, "title", "Toggle child accounts");
+
+ builder.OpenElement(sequence++, "span");
+ builder.AddAttribute(sequence++, "aria-hidden", "true");
+ builder.AddContent(sequence++, IsExpanded(accountPath) ? "▾" : "▸");
+ builder.CloseElement();
+
+ builder.CloseElement();
+ }
+ builder.CloseElement();
+
+ builder.OpenElement(sequence++, "td");
+ builder.AddContent(sequence++, account.AccountCode);
+ builder.CloseElement();
+
+ builder.OpenElement(sequence++, "td");
+ builder.AddAttribute(sequence++, "style", $"padding-left: {level * 1.5}rem;");
+ builder.AddContent(sequence++, account.AccountName);
+ builder.CloseElement();
+
+ builder.OpenElement(sequence++, "td");
+ builder.AddContent(sequence++, account.TotalBalance);
+ builder.CloseElement();
+
+ builder.OpenElement(sequence++, "td");
+ builder.AddContent(sequence++, account.TotalDebitBalance);
+ builder.CloseElement();
+
+ builder.OpenElement(sequence++, "td");
+ builder.AddContent(sequence++, account.TotalCreditBalance);
+ builder.CloseElement();
+
+ builder.OpenElement(sequence++, "td");
+ builder.AddAttribute(sequence++, "onclick:stopPropagation", "true");
+
+ builder.OpenElement(sequence++, "div");
+ builder.AddAttribute(sequence++, "class", "d-flex gap-2 justify-content-end flex-nowrap");
+
+ builder.OpenElement(sequence++, "button");
+ builder.AddAttribute(sequence++, "class", "btn btn-success btn-sm");
+ builder.AddAttribute(sequence++, "onclick", EventCallback.Factory.Create(this, () => OpenAddModal(account)));
+ builder.AddAttribute(sequence++, "onclick:stopPropagation", "true");
+ builder.AddContent(sequence++, "Add Account");
+ builder.CloseElement();
+
+ builder.OpenElement(sequence++, "button");
+ builder.AddAttribute(sequence++, "class", "btn btn-primary btn-sm");
+ builder.AddAttribute(sequence++, "onclick", EventCallback.Factory.Create(this, () => OpenEditModal(account)));
+ builder.AddAttribute(sequence++, "onclick:stopPropagation", "true");
+ builder.AddContent(sequence++, "Edit");
+ builder.CloseElement();
+
+ builder.OpenElement(sequence++, "button");
+ builder.AddAttribute(sequence++, "class", "btn btn-danger btn-sm");
+ builder.AddAttribute(sequence++, "onclick", EventCallback.Factory.Create(this, () => OpenDeleteModal(account)));
+ builder.AddAttribute(sequence++, "onclick:stopPropagation", "true");
+ builder.AddContent(sequence++, "Delete");
+ builder.CloseElement();
+
+ builder.CloseElement(); // div
+ builder.CloseElement(); // td
+ builder.CloseElement(); // tr
+
+ if (hasChildren && IsExpanded(accountPath))
+ {
+ builder.AddContent(sequence++, RenderNestedAccounts(account.ChildAccounts!, accountPath, level + 1));
+ }
+ }
+ };
+ }
+
+ // Fetch accounts from API on initialization
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadAccountsFromApi();
+ isLoading = false;
+ }
+
+ // Load accounts from API
+ private async Task LoadAccountsFromApi()
+ {
+ try
+ {
+ string apiUrl = Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+
+ var response = await client.GetAsync($"{apiUrl}financials/accounts");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var jsonString = await response.Content.ReadAsStringAsync();
+ accounts = JsonSerializer.Deserialize>(jsonString, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ }) ?? new List();
+ }
+ else
+ {
+ getError = true;
+ }
+ }
+ catch
+ {
+ getError = true;
+ }
+
+ // Notify Blazor that the state has changed and UI needs to update
+ StateHasChanged();
+ }
+
+
+ // Open Add Modal
+ private void OpenAddModal(AccountViewModel? parent = null)
+ {
+ parentAccount = parent;
+ selectedAccount = new AccountViewModel();
+ errorMessage = string.Empty;
+ isAddModalVisible = true;
+ }
+
+ // Open Edit Modal
+ private void OpenEditModal(AccountViewModel account)
+ {
+ selectedAccount = new AccountViewModel
+ {
+ AccountCode = account.AccountCode,
+ AccountName = account.AccountName,
+ TotalBalance = account.TotalBalance,
+ TotalDebitBalance = account.TotalDebitBalance,
+ TotalCreditBalance = account.TotalCreditBalance,
+ ChildAccounts = account.ChildAccounts
+ };
+ errorMessage = string.Empty;
+ isEditModalVisible = true;
+ }
+
+ // Close Add or Edit Modal
+ private void CloseModal()
+ {
+ isAddModalVisible = false;
+ isEditModalVisible = false;
+ selectedAccount = null;
+ parentAccount = null;
+ errorMessage = string.Empty;
+ }
+
+ // Add or Update Account
+ private async Task SaveAccount()
+ {
+ if (selectedAccount == null)
+ {
+ errorMessage = "No account selected.";
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(selectedAccount.AccountCode) || string.IsNullOrWhiteSpace(selectedAccount.AccountName))
+ {
+ errorMessage = "Both Account Code and Account Name are required.";
+ return;
+ }
+
+ try
+ {
+ string apiUrl = Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+
+ var accountDto = new
+ {
+ AccountCode = selectedAccount.AccountCode,
+ AccountName = selectedAccount.AccountName,
+ ParentAccountId = parentAccount?.Id // Include parent account ID if adding a sub-account
+ };
+
+ HttpResponseMessage response;
+
+ if (isEditModalVisible)
+ {
+ // Update existing account via API
+ response = await client.PutAsJsonAsync(
+ $"{apiUrl}financials/UpdateAccount/{selectedAccount.AccountCode}",
+ accountDto);
+ }
+ else
+ {
+ // Add new account via API
+ response = await client.PostAsJsonAsync(
+ $"{apiUrl}financials/AddAccount",
+ accountDto);
+ }
+
+ if (response.IsSuccessStatusCode)
+ {
+ errorMessage = string.Empty;
+ // Close modal first to ensure UI state is updated
+ CloseModal();
+ // Reload accounts from API to get updated data
+ await LoadAccountsFromApi();
+ // StateHasChanged is called in LoadAccountsFromApi, but we also call it here
+ // to ensure the modal closes immediately
+ StateHasChanged();
+ }
+ else
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+
+ // Try to parse JSON error response for better error messages
+ string detailedError = errorContent;
+ try
+ {
+ // If the response is JSON, try to extract meaningful error messages
+ if (errorContent.Trim().StartsWith("{") || errorContent.Trim().StartsWith("["))
+ {
+ var errorObj = JsonSerializer.Deserialize(errorContent);
+ if (errorObj.TryGetProperty("errors", out var errors))
+ {
+ var errorList = new List();
+ foreach (var error in errors.EnumerateObject())
+ {
+ foreach (var msg in error.Value.EnumerateArray())
+ {
+ errorList.Add($"{error.Name}: {msg.GetString()}");
+ }
+ }
+ detailedError = string.Join("; ", errorList);
+ }
+ else if (errorObj.TryGetProperty("message", out var message))
+ {
+ detailedError = message.GetString() ?? errorContent;
+ }
+ }
+ }
+ catch
+ {
+ // If parsing fails, use the raw error content
+ }
+
+ errorMessage = $"Failed to save account ({response.StatusCode}): {detailedError}";
+ StateHasChanged(); // Update UI to show error message
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error saving account: {ex.Message}";
+ if (ex.InnerException != null)
+ {
+ errorMessage += $" ({ex.InnerException.Message})";
+ }
+ StateHasChanged(); // Update UI to show error message
+ }
+ }
+
+ // Open Delete Modal
+ private void OpenDeleteModal(AccountViewModel account)
+ {
+ selectedAccount = account;
+ isDeleteModalVisible = true;
+ errorMessage = string.Empty;
+ }
+
+ // Close Delete Modal
+ private void CloseDeleteModal()
+ {
+ isDeleteModalVisible = false;
+ selectedAccount = null;
+ errorMessage = string.Empty;
+ }
+
+ // Delete Account
+ private async Task ConfirmDeleteAccount()
+ {
+ if (selectedAccount == null)
+ {
+ errorMessage = "No account selected.";
+ StateHasChanged();
+ return;
+ }
+
+ try
+ {
+ string apiUrl = Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+
+ var response = await client.DeleteAsync(
+ $"{apiUrl}financials/DeleteAccount/{selectedAccount.AccountCode}");
+
+ if (response.IsSuccessStatusCode)
+ {
+ errorMessage = string.Empty;
+ // Close modal first to ensure UI state is updated
+ CloseDeleteModal();
+ // Reload accounts from API to reflect deletion
+ await LoadAccountsFromApi();
+ // StateHasChanged is called in LoadAccountsFromApi, but we also call it here
+ // to ensure the modal closes immediately
+ StateHasChanged();
+ }
+ else
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ errorMessage = $"Failed to delete account: {response.StatusCode}. {errorContent}";
+ StateHasChanged(); // Update UI to show error message
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error deleting account: {ex.Message}";
+ StateHasChanged(); // Update UI to show error message
+ }
+ }
+
+ // ViewModel for Accounts
+ public class AccountViewModel
+ {
+ public int Id { get; set; }
+ public int? ParentAccountId { get; set; }
+ public string AccountCode { get; set; } = string.Empty;
+ public string AccountName { get; set; } = string.Empty;
+ public decimal TotalBalance { get; set; }
+ public decimal TotalDebitBalance { get; set; }
+ public decimal TotalCreditBalance { get; set; }
+ public List ChildAccounts { get; set; } = new();
+ }
+}
\ No newline at end of file
diff --git a/src/AccountGoWeb/Components/Pages/Financial/JournalEntries.razor b/src/AccountGoWeb/Components/Pages/Financial/JournalEntries.razor
new file mode 100644
index 000000000..90e3e9dfc
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Financial/JournalEntries.razor
@@ -0,0 +1,137 @@
+@namespace AccountGoWeb.Components.Pages.Financial
+@using JournalEntryDto = Dto.Financial.JournalEntry
+@using JournalEntryLineDto = Dto.Financial.JournalEntryLine
+@using System.Net.Http.Json
+
+@inject HttpClient Http
+@inject IConfiguration Config
+@inject NavigationManager Navigation
+
+
+
+
+
+ @if (IsLoading && ErrorMessage is null)
+ {
+
Loading journal entries...
+ }
+ else if (ErrorMessage is not null)
+ {
+
Error: @ErrorMessage
+ }
+ else if (Entries is null || !Entries.Any())
+ {
+
+ No journal entries found.
+
+ }
+ else
+ {
+
+
+ }
+
+
+
+
+@code {
+ // 🔴 CHANGE THESE TYPES TO THE DTO ALIAS
+ private List? Entries;
+ private string? ErrorMessage;
+ private bool IsLoading = true;
+
+ private JournalEntryDto? SelectedEntry;
+
+ private string ViewLinkHref => SelectedEntry is null
+ ? "/Financials/JournalEntry"
+ : $"/Financials/JournalEntry?id={SelectedEntry.Id}";
+
+ protected override async Task OnInitializedAsync()
+ {
+ try
+ {
+ var baseUrl = Config["ApiUrl"];
+ if (string.IsNullOrWhiteSpace(baseUrl))
+ {
+ ErrorMessage = "ApiUrl is not configured.";
+ return;
+ }
+
+ var url = $"{baseUrl}financials/journalentries";
+
+ Entries = await Http.GetFromJsonAsync>(url);
+ }
+ catch (Exception ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ private void OnRowClick(JournalEntryDto entry)
+ {
+ SelectedEntry = entry;
+ }
+
+ private void NavigateToFinancialJournalEntry()
+ {
+ if (SelectedEntry != null)
+ {
+ Navigation.NavigateTo($"/Financials/JournalEntry?id={SelectedEntry.Id}", forceLoad: true);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/AccountGoWeb/Components/Pages/Financial/JournalEntry.razor b/src/AccountGoWeb/Components/Pages/Financial/JournalEntry.razor
new file mode 100644
index 000000000..5514438b3
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Financial/JournalEntry.razor
@@ -0,0 +1,391 @@
+@using System.Net.Http.Json
+@using Dto.Financial
+@inject IHttpClientFactory HttpFactory
+@inject IConfiguration Config
+@using JournalEntryDto = Dto.Financial.JournalEntry
+@using JournalEntryLineDto = Dto.Financial.JournalEntryLine
+
+
+
+@if (IsLoading)
+{
+ Loading...
+}
+else if (!string.IsNullOrEmpty(ErrorMessage))
+{
+ @ErrorMessage
+}
+else if (Entry is null)
+{
+ Journal entry not found.
+}
+else
+{
+ @if (!string.IsNullOrEmpty(SuccessMessage))
+ {
+ @SuccessMessage
+ }
+
+
+
+
+
+
Header
+
+
+
Date
+
@Entry.JournalDate.ToString("yyyy-MM-dd")
+
+
+
+
Reference No
+
@Entry.ReferenceNo
+
+
+
+
Voucher Type
+
@Entry.VoucherType
+
+
+
+
+
+ Status
+ @if (Entry.Posted.GetValueOrDefault())
+ {
+ Posted
+ }
+ else
+ {
+ Not Posted
+ }
+
+
+
+
+
+
Lines
+
+
+ @if (Entry.JournalEntryLines == null || Entry.JournalEntryLines.Count == 0)
+ {
+
No lines found for this journal entry.
+ }
+ else
+ {
+
+
+
+ }
+
+
+
+
+ @if (!Entry.Posted.GetValueOrDefault())
+ {
+
+
+ @if (IsSaving)
+ {
+ Saving...
+ }
+ else
+ {
+ Save
+ }
+
+
+
+ @if (IsPosting)
+ {
+ Posting...
+ }
+ else if (!Entry.ReadyForPosting.GetValueOrDefault())
+ {
+ Not Ready for Posting
+ }
+ else
+ {
+ Post
+ }
+
+ }
+
+
+}
+
+@code {
+ [Parameter] public int Id { get; set; }
+
+ private JournalEntryDto? Entry;
+ private bool IsLoading = true;
+ private bool IsPosting = false;
+ private bool IsSaving = false;
+ private string? ErrorMessage;
+ private string? SuccessMessage;
+ private List Accounts = new();
+ private decimal TotalDebit =>
+ Entry?.JournalEntryLines?
+ .Where(l => l.DrCr == 1)
+ .Sum(l => l.Amount ?? 0) ?? 0;
+
+ private decimal TotalCredit =>
+ Entry?.JournalEntryLines?
+ .Where(l => l.DrCr == 2)
+ .Sum(l => l.Amount ?? 0) ?? 0;
+
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadAccountsAsync();
+ await LoadEntryAsync();
+ }
+
+ private async Task LoadAccountsAsync()
+ {
+ try
+ {
+ var apiBaseUrl = Config["ApiUrl"];
+ if (string.IsNullOrWhiteSpace(apiBaseUrl))
+ {
+ return;
+ }
+
+ var client = HttpFactory.CreateClient();
+ var url = $"{apiBaseUrl}financials/Accounts";
+
+ var result = await client.GetFromJsonAsync>(url);
+
+ // result is a tree (each Account may have ChildAccounts)
+ var flatList = new List();
+ if (result != null)
+ {
+ FlattenAccounts(result, flatList);
+ }
+
+ Accounts = flatList;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error loading accounts: {ex.Message}");
+ }
+ }
+
+ private void FlattenAccounts(IEnumerable source, List destination)
+ {
+ foreach (var acc in source)
+ {
+ destination.Add(acc);
+
+ if (acc.ChildAccounts != null && acc.ChildAccounts.Count > 0)
+ {
+ FlattenAccounts(acc.ChildAccounts, destination);
+ }
+ }
+ }
+
+ private async Task LoadEntryAsync()
+ {
+ try
+ {
+ ErrorMessage = null;
+ IsLoading = true;
+
+ var apiBaseUrl = Config["ApiUrl"];
+ if (string.IsNullOrWhiteSpace(apiBaseUrl))
+ {
+ ErrorMessage = "ApiUrl is not configured.";
+ return;
+ }
+
+ var client = HttpFactory.CreateClient();
+ var url = $"{apiBaseUrl}financials/JournalEntry?id={Id}";
+
+ Entry = await client.GetFromJsonAsync(url);
+
+ if (Entry == null)
+ ErrorMessage = "Journal entry not found.";
+ }
+ catch (Exception ex)
+ {
+ ErrorMessage = $"Error loading journal entry: {ex.Message}";
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ private async Task PostEntry()
+ {
+ if (Entry == null)
+ return;
+
+ ErrorMessage = null;
+ SuccessMessage = null;
+ IsPosting = true;
+
+ try
+ {
+ var apiBaseUrl = Config["ApiUrl"];
+ if (string.IsNullOrWhiteSpace(apiBaseUrl))
+ {
+ ErrorMessage = "ApiUrl is not configured.";
+ return;
+ }
+
+ var client = HttpFactory.CreateClient();
+ var url = $"{apiBaseUrl}financials/PostJournalEntry";
+
+ var response = await client.PostAsJsonAsync(url, Entry);
+
+ if (response.IsSuccessStatusCode)
+ {
+ SuccessMessage = "Journal entry posted successfully.";
+ await LoadEntryAsync(); // refresh Posted/ReadyForPosting
+ }
+ else
+ {
+ var errors = await response.Content.ReadFromJsonAsync();
+ ErrorMessage = errors != null && errors.Length > 0
+ ? string.Join("; ", errors)
+ : $"Error posting (HTTP {(int)response.StatusCode})";
+ }
+ }
+ catch (Exception ex)
+ {
+ ErrorMessage = $"Unexpected error while posting: {ex.Message}";
+ }
+ finally
+ {
+ IsPosting = false;
+ }
+ }
+
+
+ private async Task SaveEntry()
+ {
+ if (Entry == null)
+ return;
+
+ ErrorMessage = null;
+ SuccessMessage = null;
+ IsSaving = true;
+
+ try
+ {
+ var apiBaseUrl = Config["ApiUrl"];
+ if (string.IsNullOrWhiteSpace(apiBaseUrl))
+ {
+ ErrorMessage = "ApiUrl is not configured.";
+ return;
+ }
+
+ var client = HttpFactory.CreateClient();
+ var url = $"{apiBaseUrl}financials/SaveJournalEntry";
+
+ var response = await client.PostAsJsonAsync(url, Entry);
+
+ if (response.IsSuccessStatusCode)
+ {
+ SuccessMessage = "Journal entry saved successfully.";
+ await LoadEntryAsync(); // refresh from DB (lines, totals, ReadyForPosting)
+ }
+ else
+ {
+ var errors = await response.Content.ReadFromJsonAsync();
+ ErrorMessage = errors != null && errors.Length > 0
+ ? string.Join("; ", errors)
+ : $"Error saving (HTTP {(int)response.StatusCode})";
+ }
+ }
+ catch (Exception ex)
+ {
+ ErrorMessage = $"Unexpected error while saving: {ex.Message}";
+ }
+ finally
+ {
+ IsSaving = false;
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/src/AccountGoWeb/Components/Pages/Inventory/ICJ.razor b/src/AccountGoWeb/Components/Pages/Inventory/ICJ.razor
new file mode 100644
index 000000000..b0e719933
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Inventory/ICJ.razor
@@ -0,0 +1,210 @@
+@* ICJ.razor - fetches its own data *@
+@using Dto.Inventory
+@using Microsoft.Extensions.Configuration
+@inject IHttpClientFactory HttpClientFactory
+@inject IConfiguration Configuration
+
+@if (isLoading)
+{
+
+}
+else if (hasError)
+{
+ Error loading inventory data.
+}
+else
+{
+
+
+
+ Total IN: @TotalIn
+
+
+ Total OUT: @TotalOut
+
+
+ Current Stock: @CurrentStock
+
+
+
+
+
+ Id
+ Item
+ Unit
+ IN
+ OUT
+ Balance
+ Date
+
+
+
+ @if (ProcessedData == null || ProcessedData.Count == 0)
+ {
+
+ No Rows to Show
+
+ }
+ else
+ {
+ @foreach (var row in ProcessedData)
+ {
+ OnRowClicked(row)" class="icjRow">
+ @row.Id
+ @row.Item
+ @row.Measurement
+ @row.In
+ @row.Out
+ @row.Balance
+ @row.Date.ToShortDateString()
+
+ }
+ }
+
+
+}
+
+
+
+@code {
+
+
+
+ // summmary variables
+ // all initialized to 0 to avoid null issues, but they will be calculated from the data
+ private decimal TotalIn = 0;
+ private decimal TotalOut = 0;
+ private decimal CurrentStock = 0;
+ private List InventoryData{ get; set; } = new();
+ private bool isLoading = true;
+ private bool hasError = false;
+
+ protected override async Task OnInitializedAsync()
+ {
+ try
+ {
+ var httpClient = HttpClientFactory.CreateClient();
+ httpClient.BaseAddress = new Uri(Configuration["ApiUrl"] ?? "https://gdbapi.azurewebsites.net/api/");
+ httpClient.DefaultRequestHeaders.Accept.Clear();
+ httpClient.DefaultRequestHeaders.Accept.Add(
+ new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
+
+ var response = await httpClient.GetAsync("Inventory/ICJ");
+ if (response.IsSuccessStatusCode)
+ {
+ InventoryData = await response.Content.ReadFromJsonAsync>() ?? new List();
+ // Processing data after the fetch to calculate running balance
+ var sorted = InventoryData
+ .OrderBy(x => x.Date)
+ .ToList();
+
+decimal runningBalance = 0;
+
+ProcessedData = sorted.Select(row =>
+{
+
+
+ var inValue = row.In ?? 0;
+ var outValue = row.Out ?? 0;
+
+ runningBalance += inValue - outValue;
+
+ return new ICJRowView
+ {
+ Id = row.Id,
+ Item = row.Item,
+ Measurement = row.Measurement,
+ In = inValue,
+ Out = outValue,
+ Date = row.Date,
+ Balance = runningBalance
+ };
+}).ToList();
+
+TotalIn = ProcessedData.Sum(x => x.In);
+TotalOut = ProcessedData.Sum(x => x.Out);
+CurrentStock = ProcessedData.LastOrDefault()?.Balance ?? 0;
+ }
+ else
+ {
+ hasError = true;
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("Error loading ICJ data: " + ex.Message);
+ hasError = true;
+ }
+ finally
+ {
+ isLoading = false;
+ }
+ }
+
+ private void OnRowClicked(ICJRowView row)
+ {
+ Console.WriteLine($"Row clicked: {row.Id} - {row.Item}");
+ }
+
+ // adding a view Model
+ private class ICJRowView
+{
+ public int Id { get; set; }
+ public string? Item { get; set; }
+ public string? Measurement { get; set; }
+ public decimal In { get; set; }
+ public decimal Out { get; set; }
+ public DateTime Date { get; set; }
+ public decimal Balance { get; set; }
+}
+
+// creating a processed list
+private List ProcessedData { get; set; } = new();
+
+
+}
diff --git a/src/AccountGoWeb/Components/Pages/Inventory/ItemForm.razor b/src/AccountGoWeb/Components/Pages/Inventory/ItemForm.razor
new file mode 100644
index 000000000..e0de4036e
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Inventory/ItemForm.razor
@@ -0,0 +1,413 @@
+@using Dto.Inventory
+@using Microsoft.AspNetCore.Components.Forms
+@inject IHttpClientFactory HttpClientFactory
+@inject NavigationManager Navigation
+
+
+
+ Edit
+
+
+
+
+
+
+
+ @* General Section *@
+
+
+
+
+
+
+
Smallest UOM
+
+
+ Select...
+ @foreach (var measurement in measurements)
+ {
+ @measurement.Text
+ }
+
+
+
+
+
Category
+
+
+ Select...
+ @foreach (var category in itemCategories)
+ {
+ @category.Text
+ }
+
+
+
+
+
Item Tax Group
+
+
+ Select...
+ @foreach (var taxGroup in itemTaxGroups)
+ {
+ @taxGroup.Text
+ }
+
+
+
+
+
+
+
+ @* Pricing Section *@
+
+
+
+
+
+
Sell Description
+
+
+
+
+
+
+
Sell UOM
+
+
+ Select...
+ @foreach (var measurement in measurements)
+ {
+ @measurement.Text
+ }
+
+
+
+
+
+
+
Purchase Description
+
+
+
+
+
+
+
Purchase UOM
+
+
+ Select...
+ @foreach (var measurement in measurements)
+ {
+ @measurement.Text
+ }
+
+
+
+
+
+
+
+ @* Invoicing Section *@
+
+
+
+
+
+
Sales Account
+
+
+ Select...
+ @foreach (var account in accounts)
+ {
+ @account.Text
+ }
+
+
+
+
+
+
+
Adjustment
+
+
+ Select...
+ @foreach (var account in accounts)
+ {
+ @account.Text
+ }
+
+
+
+
+
Inventory Account
+
+
+ Select...
+ @foreach (var account in accounts)
+ {
+ @account.Text
+ }
+
+
+
+
+
Cost of Good Sold
+
+
+ Select...
+ @foreach (var account in accounts)
+ {
+ @account.Text
+ }
+
+
+
+
+
+
+
+
+
+
+ @if (isSaving)
+ {
+
+ }
+ Save
+
+
Close
+
+ @if (!string.IsNullOrEmpty(errorMessage))
+ {
+
@errorMessage
+ }
+ @if (!string.IsNullOrEmpty(successMessage))
+ {
+
@successMessage
+ }
+
+
+
+@code {
+ [Parameter]
+ public int ItemId { get; set; }
+
+ private Item item = new Item();
+ private bool isEditMode = false;
+ private bool isSaving = false;
+ private string? errorMessage;
+ private string? successMessage;
+
+ private List accounts = new();
+ private List measurements = new();
+ private List itemCategories = new();
+ private List itemTaxGroups = new();
+
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadDropdownData();
+
+ if (ItemId == 0)
+ {
+ // New item
+ isEditMode = true;
+ item.No = new Random().Next(1, 99999).ToString();
+ }
+ else
+ {
+ // Edit existing item
+ await LoadItem();
+ }
+ }
+
+ private async Task LoadItem()
+ {
+ try
+ {
+ var http = HttpClientFactory.CreateClient();
+ var apiUrl = Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var response = await http.GetAsync($"{apiUrl}inventory/item?id={ItemId}");
+
+ if (response.IsSuccessStatusCode)
+ {
+ item = await response.Content.ReadFromJsonAsync- () ?? new Item();
+ }
+ else
+ {
+ errorMessage = "Failed to load item data.";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading item: {ex.Message}";
+ }
+ }
+
+ private async Task LoadDropdownData()
+ {
+ try
+ {
+ var http = HttpClientFactory.CreateClient();
+ var apiUrl = Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+
+ // Load accounts
+ var accountsResponse = await http.GetAsync($"{apiUrl}financials/accounts");
+ if (accountsResponse.IsSuccessStatusCode)
+ {
+ var accountsList = await accountsResponse.Content.ReadFromJsonAsync
>() ?? new();
+ accounts = accountsList.Select(a => new SelectListItem { Value = a.Id.ToString(), Text = a.AccountName }).ToList();
+ }
+
+ // Load measurements
+ var measurementsResponse = await http.GetAsync($"{apiUrl}common/measurements");
+ if (measurementsResponse.IsSuccessStatusCode)
+ {
+ var measurementsList = await measurementsResponse.Content.ReadFromJsonAsync>() ?? new();
+ measurements = measurementsList.Select(m => new SelectListItem
+ {
+ Value = m.Id.ToString(),
+ Text = m.Description
+ }).ToList();
+ }
+
+ // Load item categories
+ var categoriesResponse = await http.GetAsync($"{apiUrl}common/itemcategories");
+ if (categoriesResponse.IsSuccessStatusCode)
+ {
+ var categoriesList = await categoriesResponse.Content.ReadFromJsonAsync>() ?? new();
+ itemCategories = categoriesList.Select(c => new SelectListItem { Value = c.Id.ToString(), Text = c.Name }).ToList();
+ }
+
+ // Load item tax groups
+ var taxGroupsResponse = await http.GetAsync($"{apiUrl}tax/itemtaxgroups");
+ if (taxGroupsResponse.IsSuccessStatusCode)
+ {
+ var taxGroupsList = await taxGroupsResponse.Content.ReadFromJsonAsync>() ?? new();
+ itemTaxGroups = taxGroupsList.Select(t => new SelectListItem { Value = t.Id.ToString(), Text = t.Name }).ToList();
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading dropdown data: {ex.Message}";
+ }
+ }
+
+ private void ToggleEdit()
+ {
+ isEditMode = !isEditMode;
+ }
+
+ private async Task HandleValidSubmit()
+ {
+ isSaving = true;
+ errorMessage = null;
+ successMessage = null;
+
+ try
+ {
+ var http = HttpClientFactory.CreateClient();
+ var apiUrl = Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var response = await http.PostAsJsonAsync($"{apiUrl}inventory/saveitem", item);
+
+ if (response.IsSuccessStatusCode)
+ {
+ successMessage = "Item saved successfully!";
+ await Task.Delay(1000);
+ Navigation.NavigateTo("/Inventory");
+ }
+ else
+ {
+ errorMessage = $"Failed to save item. Status: {response.StatusCode}";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error saving item: {ex.Message}";
+ }
+ finally
+ {
+ isSaving = false;
+ }
+ }
+
+ private class SelectListItem
+ {
+ public string Value { get; set; } = "";
+ public string Text { get; set; } = "";
+ }
+
+ private class Account
+ {
+ public int Id { get; set; }
+ public string AccountName { get; set; } = "";
+ }
+
+ private class Measurement
+ {
+ public int Id { get; set; }
+ public string Description { get; set; } = "";
+ }
+
+ private class ItemCategory
+ {
+ public int Id { get; set; }
+ public string Name { get; set; } = "";
+ }
+
+ private class ItemTaxGroup
+ {
+ public int Id { get; set; }
+ public string Name { get; set; } = "";
+ }
+}
\ No newline at end of file
diff --git a/src/AccountGoWeb/Components/Pages/Inventory/Items.razor b/src/AccountGoWeb/Components/Pages/Inventory/Items.razor
new file mode 100644
index 000000000..c8d470e24
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Inventory/Items.razor
@@ -0,0 +1,272 @@
+@using Dto.Inventory
+@inject HttpClient Http
+
+@inject NavigationManager Navigation
+
+
+
+
+
+
+
+
+
+ @if (isLoading)
+ {
+
+
+
+ Loading...
+
+
Loading items...
+
+
+ }
+
+ else if (errorMessage != null)
+
+ {
+
+ }
+
+ else if (items == null || !items.Any())
+
+ {
+
+ }
+ else
+ {
+
+
+
+
+
+
+
+ SortBy(nameof(Item.Id))" style="cursor: pointer;">
+ Item @GetSortIcon(nameof(Item.Id))
+
+ SortBy(nameof(Item.Code))" style="cursor: pointer;">
+ Code @GetSortIcon(nameof(Item.Code))
+
+ SortBy(nameof(Item.Description))" style="cursor: pointer;">
+ Description @GetSortIcon(nameof(Item.Description))
+
+ SortBy(nameof(Item.Measurement))" style="cursor: pointer;">
+ Unit @GetSortIcon(nameof(Item.Measurement))
+
+ SortBy(nameof(Item.ItemTaxGroupName))" style="cursor: pointer;">
+ Item Tax Group @GetSortIcon(nameof(Item.ItemTaxGroupName))
+
+ SortBy(nameof(Item.Cost))" style="cursor: pointer;">
+ Cost @GetSortIcon(nameof(Item.Cost))
+
+ SortBy(nameof(Item.Price))" style="cursor: pointer;">
+ Price @GetSortIcon(nameof(Item.Price))
+
+ SortBy(nameof(Item.QuantityOnHand))" style="cursor: pointer;">
+ On Hand @GetSortIcon(nameof(Item.QuantityOnHand))
+
+
+
+
+
+ @foreach (var item in items)
+ {
+ SelectItem(item)"
+ @ondblclick="NavigateToInventoryItem"
+ class="@(selectedItem?.Id == item.Id ? "table-active" : "")" style="cursor: pointer;">
+
+
+
+ @item.Id
+
+
+
+ @item.Code
+ @item.Description
+ @item.Measurement
+ @item.ItemTaxGroupName
+
+ @item.Cost?.ToString("N2")
+ @item.Price?.ToString("N2")
+ @item.QuantityOnHand?.ToString("N2")
+
+ }
+
+
+
+
+
+
+
+
+
+
Total: @items.Count() item(s)
+
+
+ }
+
+
+@code {
+ private List- ? items;
+ private List
- ? allItems;
+ private Item? selectedItem;
+ private bool isLoading = true;
+ private string? errorMessage;
+ private string? sortColumn;
+ private bool sortAscending = true;
+
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadItems();
+ }
+
+ private async Task LoadItems()
+ {
+ isLoading = true;
+ errorMessage = null;
+
+ try
+ {
+ string baseApiUrl = Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var apiUrl = baseApiUrl + "inventory/items";
+ var response = await Http.GetAsync(apiUrl);
+
+ if (response.IsSuccessStatusCode)
+ {
+ allItems = await response.Content.ReadFromJsonAsync
>();
+ items = allItems;
+ }
+ else
+ {
+ errorMessage = $"Failed to load items. Status: {response.StatusCode}";
+ }
+ }
+
+ catch (HttpRequestException ex)
+ {
+ errorMessage = $"Network error: {ex.Message}. Please ensure the API is running on port 8001.";
+ }
+
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading items: {ex.Message}";
+ }
+
+ finally
+ {
+ isLoading = false;
+ }
+ }
+
+ private void SelectItem(Item item)
+ {
+ selectedItem = item;
+ }
+
+ private void NavigateToInventoryItem()
+ {
+ if (selectedItem != null)
+ {
+ Navigation.NavigateTo($"/Inventory/Item/{selectedItem.Id}", forceLoad: true);
+ }
+ }
+
+ private void SortBy(string column)
+ {
+ if (items == null) return;
+
+ if (sortColumn == column)
+ {
+ sortAscending = !sortAscending;
+ }
+ else
+ {
+ sortColumn = column;
+ sortAscending = true;
+ }
+
+ items = column switch
+ {
+ nameof(Item.Id) => sortAscending
+ ? items.OrderBy(i => i.Id).ToList()
+ : items.OrderByDescending(i => i.Id).ToList(),
+
+ nameof(Item.Code) => sortAscending
+ ? items.OrderBy(i => i.Code).ToList()
+ : items.OrderByDescending(i => i.Code).ToList(),
+
+ nameof(Item.Description) => sortAscending
+ ? items.OrderBy(i => i.Description).ToList()
+ : items.OrderByDescending(i => i.Description).ToList(),
+
+ nameof(Item.Measurement) => sortAscending
+ ? items.OrderBy(i => i.Measurement).ToList()
+ : items.OrderByDescending(i => i.Measurement).ToList(),
+
+ nameof(Item.ItemTaxGroupName) => sortAscending
+ ? items.OrderBy(i => i.ItemTaxGroupName).ToList()
+ : items.OrderByDescending(i => i.ItemTaxGroupName).ToList(),
+
+ nameof(Item.Cost) => sortAscending
+ ? items.OrderBy(i => i.Cost).ToList()
+ : items.OrderByDescending(i => i.Cost).ToList(),
+
+ nameof(Item.Price) => sortAscending
+ ? items.OrderBy(i => i.Price).ToList()
+ : items.OrderByDescending(i => i.Price).ToList(),
+
+ nameof(Item.QuantityOnHand) => sortAscending
+ ? items.OrderBy(i => i.QuantityOnHand).ToList()
+ : items.OrderByDescending(i => i.QuantityOnHand).ToList(),
+ _ => items
+ };
+ }
+
+ private string GetSortIcon(string column)
+ {
+ if (sortColumn != column) return "";
+ return sortAscending ? "▲" : "▼";
+ }
+}
diff --git a/src/AccountGoWeb/Components/Pages/Payables/PurchaseInvoiceForm.razor b/src/AccountGoWeb/Components/Pages/Payables/PurchaseInvoiceForm.razor
new file mode 100644
index 000000000..7f49bacad
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Payables/PurchaseInvoiceForm.razor
@@ -0,0 +1,453 @@
+@rendermode InteractiveServer
+@using System.Text.Json
+@inject IHttpClientFactory ClientFactory
+@inject IConfiguration Configuration
+@inject NavigationManager NavigationManager
+@inject IJSRuntime JSRuntime
+
+
+
+@if (loadError)
+{
+ Unable to load purchase invoice. Please try again.
+}
+else if (purchaseInvoice is null)
+{
+ Loading...
+}
+else
+{
+
+ @if (InvoiceId > 0)
+ {
+
+ @(isViewMode ? "Edit" : "Cancel")
+
+ }
+
+
+
+
+
+ }
+ @if (saveSuccess)
+ {
+ Purchase invoice saved successfully!
+ }
+}
+
+@code {
+ [Parameter]
+ public int InvoiceId { get; set; } = 0;
+
+ private PurchaseInvoiceDto? purchaseInvoice;
+ private List? vendors, items, measurements;
+ private bool loadError, saveError, saveSuccess, isViewMode = true, isSaving = false;
+ private string? errorMessage;
+
+ public class SelectListItem { public string Value { get; set; } = ""; public string Text { get; set; } = ""; }
+
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadReferenceData();
+ if (InvoiceId > 0)
+ {
+ await LoadPurchaseInvoice(InvoiceId);
+ isViewMode = true;
+ }
+ else
+ {
+ InitializeNewInvoice();
+ isViewMode = false;
+ }
+ }
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (firstRender)
+ {
+ try
+ {
+ await JSRuntime.InvokeVoidAsync("fixTableBackground");
+ }
+ catch { }
+ }
+ }
+
+ private void InitializeNewInvoice()
+ {
+ var random = new Random();
+ purchaseInvoice = new PurchaseInvoiceDto
+ {
+ No = random.Next(1, 99999).ToString(),
+ InvoiceDate = DateTime.Now,
+ ReferenceNo = "",
+ AmountPaid = 0,
+ IsPaid = false,
+ Posted = false,
+ PurchaseInvoiceLines = new List
+ {
+ new PurchaseInvoiceLineDto { Quantity = 1, Amount = 0, Discount = 0 }
+ }
+ };
+ }
+
+ private async Task LoadReferenceData()
+ {
+ try
+ {
+ string apiurl = Configuration["ApiUrl"] ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+
+ var vendorResponse = await client.GetAsync($"{apiurl}purchasing/vendors");
+ if (vendorResponse.IsSuccessStatusCode)
+ {
+ var json = await vendorResponse.Content.ReadAsStringAsync();
+ var list = JsonSerializer.Deserialize>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ vendors = list?.Select(v => new SelectListItem { Value = v.Id.ToString(), Text = v.Name ?? "" }).ToList();
+ }
+
+ var itemResponse = await client.GetAsync($"{apiurl}inventory/items");
+ if (itemResponse.IsSuccessStatusCode)
+ {
+ var json = await itemResponse.Content.ReadAsStringAsync();
+ var list = JsonSerializer.Deserialize>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ items = list?.Select(i => new SelectListItem { Value = i.Id.ToString(), Text = i.Description ?? "" }).ToList();
+ }
+
+ var measurementResponse = await client.GetAsync($"{apiurl}common/measurements");
+ if (measurementResponse.IsSuccessStatusCode)
+ {
+ var json = await measurementResponse.Content.ReadAsStringAsync();
+ var list = JsonSerializer.Deserialize>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ measurements = list?.Select(m => new SelectListItem { Value = m.Id.ToString(), Text = m.Description ?? "" }).ToList();
+ }
+ }
+ catch (Exception)
+ {
+ vendors = new List();
+ items = new List();
+ measurements = new List();
+ }
+ }
+
+ private async Task LoadPurchaseInvoice(int id)
+ {
+ try
+ {
+ string apiurl = Configuration["ApiUrl"] ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+ var response = await client.GetAsync($"{apiurl}purchasing/purchaseinvoice?id={id}");
+ if (response.IsSuccessStatusCode)
+ {
+ var json = await response.Content.ReadAsStringAsync();
+ var tempInvoice = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+
+ // Workaround: Try to get ReferenceNo from localStorage if API doesn't return it
+ if (tempInvoice != null)
+ {
+ purchaseInvoice = tempInvoice;
+
+ // If API doesn't return ReferenceNo, try to get it from the list
+ if (string.IsNullOrEmpty(purchaseInvoice.ReferenceNo))
+ {
+ try
+ {
+ var listResponse = await client.GetAsync($"{apiurl}purchasing/purchaseinvoices");
+ if (listResponse.IsSuccessStatusCode)
+ {
+ var listJson = await listResponse.Content.ReadAsStringAsync();
+ var invoicesList = JsonSerializer.Deserialize>(listJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ var invoiceFromList = invoicesList?.FirstOrDefault(i => i.Id == id);
+ if (invoiceFromList != null && !string.IsNullOrEmpty(invoiceFromList.ReferenceNo))
+ {
+ purchaseInvoice.ReferenceNo = invoiceFromList.ReferenceNo;
+ }
+ }
+ }
+ catch { }
+ }
+ }
+ }
+ else
+ {
+ loadError = true;
+ }
+ }
+ catch (Exception)
+ {
+ loadError = true;
+ }
+ }
+
+ private async Task HandleSubmit()
+ {
+ try
+ {
+ isSaving = true;
+ saveError = false;
+ saveSuccess = false;
+ errorMessage = null;
+
+ if (purchaseInvoice != null && purchaseInvoice.PurchaseInvoiceLines != null)
+ {
+ var validLines = purchaseInvoice.PurchaseInvoiceLines
+ .Where(line => line.ItemId.HasValue && line.ItemId.Value > 0)
+ .ToList();
+
+ if (validLines.Count == 0)
+ {
+ errorMessage = "Please add at least one item line with a valid item selected.";
+ saveError = true;
+ isSaving = false;
+ return;
+ }
+
+ purchaseInvoice.PurchaseInvoiceLines = validLines;
+ }
+
+ string apiurl = Configuration["ApiUrl"] ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+ var json = JsonSerializer.Serialize(purchaseInvoice);
+ var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
+ var response = await client.PostAsync($"{apiurl}purchasing/savepurchaseinvoice", content);
+
+ if (response.IsSuccessStatusCode)
+ {
+ saveSuccess = true;
+ StateHasChanged();
+ await Task.Delay(500);
+ NavigationManager.NavigateTo("/purchasing/purchaseinvoices", forceLoad: true);
+ }
+ else
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ errorMessage = $"API returned {response.StatusCode}: {errorContent}";
+ saveError = true;
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = ex.Message;
+ saveError = true;
+ }
+ finally
+ {
+ isSaving = false;
+ }
+ }
+
+ private void ToggleEdit() { isViewMode = !isViewMode; }
+ private void AddLine() { purchaseInvoice?.PurchaseInvoiceLines?.Add(new PurchaseInvoiceLineDto { Quantity = 1, Amount = 0, Discount = 0 }); }
+ private void RemoveLine(int index)
+ {
+ if (purchaseInvoice?.PurchaseInvoiceLines != null && purchaseInvoice.PurchaseInvoiceLines.Count > 1)
+ purchaseInvoice.PurchaseInvoiceLines.RemoveAt(index);
+ }
+
+ public class PurchaseInvoiceDto
+ {
+ public int Id { get; set; }
+ public string? No { get; set; }
+ public int VendorId { get; set; }
+ public DateTime InvoiceDate { get; set; }
+ public decimal AmountPaid { get; set; }
+ public bool IsPaid { get; set; }
+ public bool Posted { get; set; }
+ public string? VendorInvoiceNo { get; set; }
+ public string? ReferenceNo { get; set; }
+ public List PurchaseInvoiceLines { get; set; } = new();
+ }
+
+ public class PurchaseInvoiceLineDto
+ {
+ public int Id { get; set; }
+ public int? ItemId { get; set; }
+ public int? MeasurementId { get; set; }
+ public decimal? Quantity { get; set; }
+ public decimal? Amount { get; set; }
+ public decimal? Discount { get; set; }
+ }
+
+ public class VendorDto { public int Id { get; set; } public string? Name { get; set; } }
+ public class ItemDto { public int Id { get; set; } public string? Description { get; set; } }
+ public class MeasurementDto { public int Id { get; set; } public string? Description { get; set; } }
+}
+
diff --git a/src/AccountGoWeb/Components/Pages/Payables/PurchaseInvoiceForm.razor.css b/src/AccountGoWeb/Components/Pages/Payables/PurchaseInvoiceForm.razor.css
new file mode 100644
index 000000000..9dd4ffb54
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Payables/PurchaseInvoiceForm.razor.css
@@ -0,0 +1,32 @@
+.card-body .row .col-sm-3 {
+ color: white;
+ font-weight: 500;
+ padding-top: 0.375rem;
+}
+
+.table thead th {
+ color: white;
+ background-color: #343a40;
+}
+
+.invoice-form-wrapper .table-container .table {
+ --bs-table-hover-bg: transparent;
+ --bs-table-active-bg: transparent;
+ --bs-table-striped-bg: transparent;
+}
+
+.invoice-form-wrapper .table-container table tbody tr,
+.invoice-form-wrapper .table-container table tbody tr td {
+ background-color: transparent !important;
+}
+
+.invoice-form-wrapper .table-container table tbody tr td select:focus,
+.invoice-form-wrapper .table-container table tbody tr td input:focus,
+.invoice-form-wrapper .table-container table tbody tr td input[type="number"]:focus,
+.invoice-form-wrapper .table-container table tbody tr td .form-control:focus {
+ border: 2px solid #0d6efd !important;
+ box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25) !important;
+ background-color: #fff !important;
+ outline: none !important;
+}
+
diff --git a/src/AccountGoWeb/Components/Pages/Payables/PurchaseInvoicesList.razor b/src/AccountGoWeb/Components/Pages/Payables/PurchaseInvoicesList.razor
new file mode 100644
index 000000000..bb89e41fe
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Payables/PurchaseInvoicesList.razor
@@ -0,0 +1,161 @@
+@rendermode InteractiveServer
+@using System.Text.Json
+@using System.Text.Json.Serialization
+@inject IHttpClientFactory ClientFactory
+
+@inject NavigationManager Navigation
+
+@if (getError)
+{
+
+ Unable to get data. Please try again later.
+
+}
+else if (purchaseInvoices is null)
+{
+ Loading...
+}
+else
+{
+
+
+
+
+
+
+ No
+ Vendor Name
+ Invoice Date
+ Amount
+ Amount Paid
+ Ref No
+ Actions
+
+
+
+ @foreach (var invoice in purchaseInvoices)
+ {
+ SelectInvoice(invoice)"
+ @ondblclick="NavigateToPurchaseInvoice"
+ class="@(selectedInvoice?.Id == invoice.Id ? "table-active" : "")"
+ style="cursor: pointer;">
+ @invoice.No
+ @invoice.VendorName
+ @invoice.InvoiceDate.ToString("yyyy-MM-dd")
+ @("$" + invoice.Amount.ToString("N2"))
+ @("$" + invoice.AmountPaid.ToString("N2"))
+ @invoice.ReferenceNo
+
+ @if (!invoice.IsPaid && invoice.Posted)
+ {
+
+ Pay
+
+ }
+
+
+ }
+
+
+
+}
+
+@code {
+ private List? purchaseInvoices;
+ private PurchaseInvoiceDto? selectedInvoice;
+ private bool getError;
+ private bool shouldRender = true;
+
+ protected override bool ShouldRender() => shouldRender;
+
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadPurchaseInvoices();
+ }
+
+ private async Task LoadPurchaseInvoices()
+ {
+ try
+ {
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+
+ var request = new HttpRequestMessage(HttpMethod.Get, $"{apiurl}purchasing/purchaseinvoices");
+ request.Headers.Add("Accept", "application/json");
+
+ var client = ClientFactory.CreateClient();
+ client.DefaultRequestHeaders.Accept.Clear();
+ client.DefaultRequestHeaders.Clear();
+ client.DefaultRequestHeaders.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue() { NoCache = true };
+ client.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
+
+ var response = await client.SendAsync(request);
+
+ if (response.IsSuccessStatusCode)
+ {
+ var responseString = await response.Content.ReadAsStringAsync();
+ var options = new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ purchaseInvoices = JsonSerializer.Deserialize>(responseString, options);
+ }
+ else
+ {
+ getError = true;
+ }
+ }
+ catch (Exception)
+ {
+ getError = true;
+ }
+
+ shouldRender = true;
+ }
+
+ private void SelectInvoice(PurchaseInvoiceDto invoice)
+ {
+ selectedInvoice = invoice;
+ StateHasChanged();
+ }
+
+ private void NavigateToPurchaseInvoice()
+ {
+ if (selectedInvoice != null)
+ {
+ Navigation.NavigateTo($"/purchasing/purchaseinvoice?id={selectedInvoice.Id}", forceLoad: true);
+ }
+ }
+
+ public class PurchaseInvoiceDto
+ {
+ public int Id { get; set; }
+ public string? No { get; set; }
+ public int VendorId { get; set; }
+ public string? VendorName { get; set; }
+ public DateTime InvoiceDate { get; set; }
+ public decimal Amount { get; set; }
+ public decimal AmountPaid { get; set; }
+ public string? ReferenceNo { get; set; }
+ public bool Posted { get; set; }
+ public bool IsPaid { get; set; }
+ }
+}
+
diff --git a/src/AccountGoWeb/Components/Pages/Payables/PurchaseInvoicesList.razor.css b/src/AccountGoWeb/Components/Pages/Payables/PurchaseInvoicesList.razor.css
new file mode 100644
index 000000000..9adbc4212
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Payables/PurchaseInvoicesList.razor.css
@@ -0,0 +1,20 @@
+.table {
+ --bs-table-hover-bg: rgba(255, 255, 255, 0.05);
+ --bs-table-active-bg: rgba(13, 110, 253, 0.2);
+ --bs-table-striped-bg: transparent;
+}
+
+.table tbody tr.table-active,
+.table tbody tr.table-active td {
+ background-color: rgba(13, 110, 253, 0.2) !important;
+ border-left: 3px solid #0d6efd;
+}
+
+.table tbody tr:hover {
+ background-color: rgba(255, 255, 255, 0.05) !important;
+}
+
+.table tbody tr.table-active:hover {
+ background-color: rgba(13, 110, 253, 0.25) !important;
+}
+
diff --git a/src/AccountGoWeb/Components/Pages/Payables/PurchaseOrderForm.razor b/src/AccountGoWeb/Components/Pages/Payables/PurchaseOrderForm.razor
new file mode 100644
index 000000000..da835c9be
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Payables/PurchaseOrderForm.razor
@@ -0,0 +1,466 @@
+@rendermode InteractiveServer
+@using System.Text.Json
+@using System.Text.Json.Serialization
+@inject IHttpClientFactory ClientFactory
+@inject IConfiguration Configuration
+@inject NavigationManager NavigationManager
+@inject IJSRuntime JSRuntime
+
+
+
+@if (loadError)
+{
+
+ Unable to load purchase order. Please try again later.
+
+}
+else if (purchaseOrder is null)
+{
+ Loading...
+}
+else
+{
+
+ @if (OrderId > 0)
+ {
+
+ @(isViewMode ? "Edit" : "Cancel Edit")
+
+ }
+
+
+
+
+
+}
+
+@code {
+ [Parameter]
+ public int OrderId { get; set; } = 0;
+
+ private PurchaseOrderDto? purchaseOrder;
+ private List? vendors;
+ private List? items;
+ private List? measurements;
+ private bool loadError;
+ private bool saveError;
+ private bool saveSuccess;
+ private bool isViewMode = true;
+ private bool isSaving = false;
+ private string? errorMessage;
+
+ public class SelectListItem
+ {
+ public string Value { get; set; } = "";
+ public string Text { get; set; } = "";
+ }
+
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadReferenceData();
+
+ if (OrderId > 0)
+ {
+ await LoadPurchaseOrder(OrderId);
+ isViewMode = true;
+ }
+ else
+ {
+ InitializeNewPurchaseOrder();
+ isViewMode = false;
+ }
+ }
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (firstRender)
+ {
+ try
+ {
+ await JSRuntime.InvokeVoidAsync("fixTableBackgroundOrder");
+ }
+ catch { }
+ }
+ }
+
+ private void InitializeNewPurchaseOrder()
+ {
+ var random = new Random();
+ purchaseOrder = new PurchaseOrderDto
+ {
+ No = random.Next(1, 99999).ToString(),
+ OrderDate = DateTime.Now,
+ ReferenceNo = "",
+ Completed = false,
+ PurchaseOrderLines = new List
+ {
+ new PurchaseOrderLineDto { Quantity = 1, Amount = 0, Discount = 0, ItemId = 0, MeasurementId = 0 }
+ }
+ };
+ }
+
+ private async Task LoadReferenceData()
+ {
+ try
+ {
+ string apiurl = Configuration["ApiUrl"] ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+
+ // Load vendors
+ var vendorResponse = await client.GetAsync($"{apiurl}purchasing/vendors");
+ if (vendorResponse.IsSuccessStatusCode)
+ {
+ var vendorJson = await vendorResponse.Content.ReadAsStringAsync();
+ var vendorList = JsonSerializer.Deserialize>(vendorJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ vendors = vendorList?.Select(v => new SelectListItem { Value = v.Id.ToString(), Text = v.Name ?? "" }).ToList();
+ }
+
+ // Load items
+ var itemResponse = await client.GetAsync($"{apiurl}inventory/items");
+ if (itemResponse.IsSuccessStatusCode)
+ {
+ var itemJson = await itemResponse.Content.ReadAsStringAsync();
+ var itemList = JsonSerializer.Deserialize>(itemJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ items = itemList?.Select(i => new SelectListItem { Value = i.Id.ToString(), Text = i.Description ?? "" }).ToList();
+ }
+
+ // Load measurements
+ var measurementResponse = await client.GetAsync($"{apiurl}common/measurements");
+ if (measurementResponse.IsSuccessStatusCode)
+ {
+ var measurementJson = await measurementResponse.Content.ReadAsStringAsync();
+ var measurementList = JsonSerializer.Deserialize>(measurementJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ measurements = measurementList?.Select(m => new SelectListItem { Value = m.Id.ToString(), Text = m.Description ?? "" }).ToList();
+ }
+ }
+ catch (Exception)
+ {
+ vendors = new List();
+ items = new List();
+ measurements = new List();
+ }
+ }
+
+ private async Task LoadPurchaseOrder(int id)
+ {
+ try
+ {
+ string apiurl = Configuration["ApiUrl"] ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+ var response = await client.GetAsync($"{apiurl}purchasing/purchaseorder?id={id}");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var json = await response.Content.ReadAsStringAsync();
+ purchaseOrder = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ }
+ else
+ {
+ loadError = true;
+ }
+ }
+ catch (Exception)
+ {
+ loadError = true;
+ }
+ }
+
+ private async Task HandleSubmit()
+ {
+ try
+ {
+ isSaving = true;
+ saveError = false;
+ saveSuccess = false;
+ errorMessage = null;
+
+ if (purchaseOrder != null && purchaseOrder.PurchaseOrderLines != null)
+ {
+ var validLines = purchaseOrder.PurchaseOrderLines
+ .Where(line => line.ItemId.HasValue && line.ItemId.Value > 0)
+ .ToList();
+
+ if (validLines.Count == 0)
+ {
+ errorMessage = "Please add at least one item line with a valid item selected.";
+ saveError = true;
+ isSaving = false;
+ return;
+ }
+
+ purchaseOrder.PurchaseOrderLines = validLines;
+ }
+
+ string apiurl = Configuration["ApiUrl"] ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+ var json = JsonSerializer.Serialize(purchaseOrder);
+ var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
+ var response = await client.PostAsync($"{apiurl}purchasing/savepurchaseorder", content);
+
+ if (response.IsSuccessStatusCode)
+ {
+ saveSuccess = true;
+ StateHasChanged();
+ await Task.Delay(500);
+ NavigationManager.NavigateTo("/purchasing/purchaseorders", forceLoad: true);
+ }
+ else
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ errorMessage = $"API returned {response.StatusCode}: {errorContent}";
+ saveError = true;
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = ex.Message;
+ saveError = true;
+ }
+ finally
+ {
+ isSaving = false;
+ }
+ }
+
+ private void ToggleEdit()
+ {
+ isViewMode = !isViewMode;
+ }
+
+ private void AddLine()
+ {
+ purchaseOrder?.PurchaseOrderLines?.Add(new PurchaseOrderLineDto { Quantity = 1, Amount = 0, Discount = 0, ItemId = 0, MeasurementId = 0 });
+ }
+
+ private void RemoveLine(int index)
+ {
+ if (purchaseOrder?.PurchaseOrderLines != null && purchaseOrder.PurchaseOrderLines.Count > 1)
+ {
+ purchaseOrder.PurchaseOrderLines.RemoveAt(index);
+ }
+ }
+
+ // DTOs
+ public class PurchaseOrderDto
+ {
+ public int Id { get; set; }
+ public string? No { get; set; }
+ public int VendorId { get; set; }
+ public DateTime OrderDate { get; set; }
+ public decimal Amount => PurchaseOrderLines?.Sum(l => ((l.Quantity ?? 0) * (l.Amount ?? 0)) * (1 - ((l.Discount ?? 0) / 100))) ?? 0;
+ public bool Completed { get; set; }
+ public string? ReferenceNo { get; set; }
+ public List PurchaseOrderLines { get; set; } = new();
+ }
+
+ public class PurchaseOrderLineDto
+ {
+ public int Id { get; set; }
+ public int? ItemId { get; set; }
+ public int? MeasurementId { get; set; }
+ public decimal? Quantity { get; set; }
+ public decimal? Amount { get; set; }
+ public decimal? Discount { get; set; }
+ }
+
+ public class VendorDto
+ {
+ public int Id { get; set; }
+ public string? Name { get; set; }
+ }
+
+ public class ItemDto
+ {
+ public int Id { get; set; }
+ public string? Description { get; set; }
+ }
+
+ public class MeasurementDto
+ {
+ public int Id { get; set; }
+ public string? Description { get; set; }
+ }
+}
+
diff --git a/src/AccountGoWeb/Components/Pages/Payables/PurchaseOrderForm.razor.css b/src/AccountGoWeb/Components/Pages/Payables/PurchaseOrderForm.razor.css
new file mode 100644
index 000000000..bf127dfd3
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Payables/PurchaseOrderForm.razor.css
@@ -0,0 +1,32 @@
+.card-body .row .col-sm-3 {
+ color: white;
+ font-weight: 500;
+ padding-top: 0.375rem;
+}
+
+.table thead th {
+ color: white;
+ background-color: #343a40;
+}
+
+.order-form-wrapper .table-container .table {
+ --bs-table-hover-bg: transparent;
+ --bs-table-active-bg: transparent;
+ --bs-table-striped-bg: transparent;
+}
+
+.order-form-wrapper .table-container table tbody tr,
+.order-form-wrapper .table-container table tbody tr td {
+ background-color: transparent !important;
+}
+
+.order-form-wrapper .table-container table tbody tr td select:focus,
+.order-form-wrapper .table-container table tbody tr td input:focus,
+.order-form-wrapper .table-container table tbody tr td input[type="number"]:focus,
+.order-form-wrapper .table-container table tbody tr td .form-control:focus {
+ border: 2px solid #0d6efd !important;
+ box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25) !important;
+ background-color: #fff !important;
+ outline: none !important;
+}
+
diff --git a/src/AccountGoWeb/Components/Pages/Payables/PurchaseOrdersList.razor b/src/AccountGoWeb/Components/Pages/Payables/PurchaseOrdersList.razor
new file mode 100644
index 000000000..05439dbc4
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Payables/PurchaseOrdersList.razor
@@ -0,0 +1,147 @@
+@rendermode InteractiveServer
+@using System.Text.Json
+@using System.Text.Json.Serialization
+@inject IHttpClientFactory ClientFactory
+@inject NavigationManager Navigation
+
+@if (getError)
+{
+
+ Unable to get data. Please try again later.
+
+}
+else if (purchaseOrders is null)
+{
+ Loading...
+}
+else
+{
+
+
+
+
+
+
+ No
+ Vendor Name
+ Order Date
+ Amount
+ Ref No
+
+
+
+ @foreach (var order in purchaseOrders)
+ {
+ SelectOrder(order)"
+ @ondblclick="NavigateToPurchaseOrder"
+ class="@(selectedPurchaseOrder?.Id == order.Id ? "table-active" : "")"
+ style="cursor: pointer;">
+ @order.No
+ @order.VendorName
+ @order.OrderDate.ToString("yyyy-MM-dd")
+ @("$" + order.Amount.ToString("N2"))
+ @order.ReferenceNo
+
+ }
+
+
+
+}
+
+@code {
+ private List? purchaseOrders;
+ private PurchaseOrderDto? selectedPurchaseOrder;
+ private bool getError;
+ private bool shouldRender = true;
+
+ protected override bool ShouldRender() => shouldRender;
+
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadPurchaseOrders();
+ }
+
+ private async Task LoadPurchaseOrders()
+ {
+ try
+ {
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+
+ var request = new HttpRequestMessage(HttpMethod.Get, $"{apiurl}purchasing/purchaseorders");
+ request.Headers.Add("Accept", "application/json");
+
+ var client = ClientFactory.CreateClient();
+ client.DefaultRequestHeaders.Accept.Clear();
+ client.DefaultRequestHeaders.Clear();
+ client.DefaultRequestHeaders.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue() { NoCache = true };
+ client.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
+
+ var response = await client.SendAsync(request);
+
+ if (response.IsSuccessStatusCode)
+ {
+ var responseString = await response.Content.ReadAsStringAsync();
+ var options = new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ purchaseOrders = JsonSerializer.Deserialize>(responseString, options);
+ }
+ else
+ {
+ getError = true;
+ }
+ }
+ catch (Exception)
+ {
+ getError = true;
+ }
+
+ shouldRender = true;
+ }
+
+ private void SelectOrder(PurchaseOrderDto order)
+ {
+ selectedPurchaseOrder = order;
+ StateHasChanged();
+ }
+
+ private void NavigateToPurchaseOrder()
+ {
+ if (selectedPurchaseOrder != null)
+ {
+ Navigation.NavigateTo($"/purchasing/purchaseorder?id={selectedPurchaseOrder.Id}", forceLoad: true);
+ }
+ }
+
+ public class PurchaseOrderDto
+ {
+ public int Id { get; set; }
+ public string? No { get; set; }
+ public int VendorId { get; set; }
+ public string? VendorName { get; set; }
+ public DateTime OrderDate { get; set; }
+ public decimal Amount { get; set; }
+ public string? ReferenceNo { get; set; }
+ public int StatusId { get; set; }
+ }
+}
+
diff --git a/src/AccountGoWeb/Components/Pages/Payables/PurchaseOrdersList.razor.css b/src/AccountGoWeb/Components/Pages/Payables/PurchaseOrdersList.razor.css
new file mode 100644
index 000000000..9adbc4212
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Payables/PurchaseOrdersList.razor.css
@@ -0,0 +1,20 @@
+.table {
+ --bs-table-hover-bg: rgba(255, 255, 255, 0.05);
+ --bs-table-active-bg: rgba(13, 110, 253, 0.2);
+ --bs-table-striped-bg: transparent;
+}
+
+.table tbody tr.table-active,
+.table tbody tr.table-active td {
+ background-color: rgba(13, 110, 253, 0.2) !important;
+ border-left: 3px solid #0d6efd;
+}
+
+.table tbody tr:hover {
+ background-color: rgba(255, 255, 255, 0.05) !important;
+}
+
+.table tbody tr.table-active:hover {
+ background-color: rgba(13, 110, 253, 0.25) !important;
+}
+
diff --git a/src/AccountGoWeb/Components/Pages/Purchasing/VendorForm.razor b/src/AccountGoWeb/Components/Pages/Purchasing/VendorForm.razor
new file mode 100644
index 000000000..5a4876f9e
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Purchasing/VendorForm.razor
@@ -0,0 +1,531 @@
+@using Dto.Purchasing
+@using Dto.Common
+@using Microsoft.AspNetCore.Components.Forms
+@inject IHttpClientFactory HttpClientFactory
+@inject NavigationManager Navigation
+
+
+
+
+
+
+
+ @* General Section *@
+
+
+ @* Contact Section *@
+
+
+ @* Address Section *@
+
+
+ @* Invoicing Section *@
+
+
+
+
+
+
Accounts Payable
+
+
+
+
+
+
Purchase
+
+
+ Select...
+ @foreach (var account in accounts)
+ {
+ @account.Text
+ }
+
+
+
+
+
+
+
+
Tax Group
+
+
+ Select...
+ @foreach (var taxGroup in taxGroups)
+ {
+ @taxGroup.Text
+ }
+
+
+
+
+
+
+
+ @* Payment Section *@
+
+
+
+
+
+
Payment Term
+
+
+ Select...
+ @foreach (var term in paymentTerms)
+ {
+ @term.Text
+ }
+
+
+
+
+
Payment Method
+
+
+ Select...
+ @foreach (var method in paymentMethods)
+ {
+ @method.Text
+ }
+
+
+
+
+
+
+
+
+
+
+ @if (isSaving)
+ {
+
+ }
+ Save
+
+
Close
+
+ @if (!string.IsNullOrEmpty(errorMessage))
+ {
+
@errorMessage
+ }
+ @if (!string.IsNullOrEmpty(successMessage))
+ {
+
@successMessage
+ }
+
+
+
+@code {
+ [Parameter]
+ public int VendorId { get; set; }
+
+ private Vendor vendor = new Vendor();
+ private bool isEditMode = false;
+ private bool isSaving = false;
+ private string? errorMessage;
+ private string? successMessage;
+
+ private List accounts = new();
+ private List taxGroups = new();
+ private List paymentTerms = new();
+ private List paymentMethods = new();
+
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadDropdownData();
+
+ if (VendorId == -1 || VendorId == 0)
+ {
+ // New vendor
+ isEditMode = true;
+ vendor.No = new Random().Next(1, 99999).ToString();
+ vendor.PrimaryContact = new Contact();
+ vendor.PaymentMethod = 1;
+ var apAccount = accounts.FirstOrDefault(a => a.Text == "20110");
+ if (apAccount != null && int.TryParse(apAccount.Value, out var apId))
+ {
+ vendor.AccountsPayableAccountId = apId;
+ }
+ }
+ else
+ {
+ // Edit existing vendor
+ await LoadVendor();
+ }
+ }
+
+ private async Task LoadVendor()
+ {
+ try
+ {
+ var http = HttpClientFactory.CreateClient();
+ var apiUrl = Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var response = await http.GetAsync($"{apiUrl}purchasing/vendor?id={VendorId}");
+
+ if (response.IsSuccessStatusCode)
+ {
+ vendor = await response.Content.ReadFromJsonAsync() ?? new Vendor();
+ if (vendor.PrimaryContact == null)
+ {
+ vendor.PrimaryContact = new Contact();
+ }
+ if (vendor.PrimaryContact.Party == null)
+ {
+ vendor.PrimaryContact.Party = new Party();
+ }
+ }
+ else
+ {
+ errorMessage = "Failed to load vendor data.";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading vendor: {ex.Message}";
+ }
+ }
+
+ private async Task LoadDropdownData()
+ {
+ try
+ {
+ var http = HttpClientFactory.CreateClient();
+ var apiUrl = Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+
+ // Load accounts
+ var accountsResponse = await http.GetAsync($"{apiUrl}financials/accounts");
+ if (accountsResponse.IsSuccessStatusCode)
+ {
+ var accountTree = await accountsResponse.Content.ReadFromJsonAsync>() ?? new();
+ var accountsList = FlattenAccounts(accountTree)
+ .Where(a => a.ChildAccounts.Count == 0)
+ .OrderBy(a => a.AccountCode)
+ .ThenBy(a => a.AccountName)
+ .ToList();
+
+ accounts = accountsList.Select(a => new SelectListItem
+ {
+ Value = a.Id.ToString(),
+ Text = a.AccountCode
+ }).ToList();
+ }
+
+ // Load tax groups
+ var taxGroupsResponse = await http.GetAsync($"{apiUrl}tax/taxgroups");
+ if (taxGroupsResponse.IsSuccessStatusCode)
+ {
+ var taxGroupsList = await taxGroupsResponse.Content.ReadFromJsonAsync>() ?? new();
+ taxGroups = taxGroupsList.Select(t => new SelectListItem { Value = t.Id.ToString(), Text = t.Description }).ToList();
+ }
+
+ // Load payment terms
+ var paymentTermsResponse = await http.GetAsync($"{apiUrl}common/paymentterms");
+ if (paymentTermsResponse.IsSuccessStatusCode)
+ {
+ var paymentTermsList = await paymentTermsResponse.Content.ReadFromJsonAsync>() ?? new();
+ paymentTerms = paymentTermsList.Select(p => new SelectListItem
+ {
+ Value = p.Id.ToString(),
+ Text = BuildPaymentTermLabel(p)
+ }).ToList();
+ }
+
+ // Load payment methods
+ var paymentMethodsResponse = await http.GetAsync($"{apiUrl}common/paymentmethods");
+ if (paymentMethodsResponse.IsSuccessStatusCode)
+ {
+ var paymentMethodsList = await paymentMethodsResponse.Content.ReadFromJsonAsync>() ?? new();
+ paymentMethods = paymentMethodsList.Select(p => new SelectListItem
+ {
+ Value = p.Id.ToString(),
+ Text = p.Description
+ }).ToList();
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading dropdown data: {ex.Message}";
+ }
+ }
+
+ private void ToggleEdit()
+ {
+ isEditMode = !isEditMode;
+ }
+
+ private async Task HandleValidSubmit()
+ {
+ isSaving = true;
+ errorMessage = null;
+ successMessage = null;
+
+ try
+ {
+ var http = HttpClientFactory.CreateClient();
+ var apiUrl = Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var response = await http.PostAsJsonAsync($"{apiUrl}purchasing/savevendor", vendor);
+
+ if (response.IsSuccessStatusCode)
+ {
+ successMessage = "Vendor saved successfully!";
+ await Task.Delay(1000);
+ Navigation.NavigateTo("/Purchasing/Vendors");
+ }
+ else
+ {
+ errorMessage = $"Failed to save vendor. Status: {response.StatusCode}";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error saving vendor: {ex.Message}";
+ }
+ finally
+ {
+ isSaving = false;
+ }
+ }
+
+ private class SelectListItem
+ {
+ public string Value { get; set; } = "";
+ public string Text { get; set; } = "";
+ }
+
+ private string GetAccountsPayableDisplay()
+ {
+ if (!vendor.AccountsPayableAccountId.HasValue)
+ {
+ return string.Empty;
+ }
+
+ return accounts.FirstOrDefault(a => a.Value == vendor.AccountsPayableAccountId.Value.ToString())?.Text ?? string.Empty;
+ }
+
+ private static IEnumerable FlattenAccounts(IEnumerable accounts)
+ {
+ foreach (var account in accounts)
+ {
+ yield return account;
+
+ if (account.ChildAccounts.Count > 0)
+ {
+ foreach (var child in FlattenAccounts(account.ChildAccounts))
+ {
+ yield return child;
+ }
+ }
+ }
+ }
+
+ private static string BuildPaymentTermLabel(PaymentTerm paymentTerm)
+ {
+ if (paymentTerm.DueAfterDays.HasValue && paymentTerm.DueAfterDays.Value > 0)
+ {
+ return $"{paymentTerm.DueAfterDays.Value} days";
+ }
+
+ if (!string.IsNullOrWhiteSpace(paymentTerm.Description))
+ {
+ return paymentTerm.Description;
+ }
+
+ return "Term";
+ }
+
+ private class Account
+ {
+ public int Id { get; set; }
+ public string AccountCode { get; set; } = "";
+ public string AccountName { get; set; } = "";
+ public List ChildAccounts { get; set; } = new();
+ }
+
+ private class TaxGroup
+ {
+ public int Id { get; set; }
+ public string Description { get; set; } = "";
+ }
+
+ private class PaymentTerm
+ {
+ public int Id { get; set; }
+ public string Description { get; set; } = "";
+ public int? DueAfterDays { get; set; }
+ }
+
+ private class PaymentMethodItem
+ {
+ public int Id { get; set; }
+ public string Description { get; set; } = "";
+ }
+}
\ No newline at end of file
diff --git a/src/AccountGoWeb/Components/Pages/Purchasing/Vendors.razor b/src/AccountGoWeb/Components/Pages/Purchasing/Vendors.razor
new file mode 100644
index 000000000..d08ef4ba8
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Purchasing/Vendors.razor
@@ -0,0 +1,256 @@
+@using Dto.Purchasing
+@inject HttpClient Http
+
+@inject NavigationManager Navigation
+
+
+
+
+
+
+
+
+
+ @if (isLoading)
+ {
+
+
+
+ Loading...
+
+
Loading vendors...
+
+
+ }
+
+ else if (errorMessage != null)
+
+ {
+
+ }
+
+ else if (vendors == null || !vendors.Any())
+
+ {
+
+
+
+ No vendors found.
+
+
+
+ }
+ else
+ {
+
+
+
+
+
+
+
+ SortBy(nameof(Vendor.No))" style="cursor: pointer;">
+ No @GetSortIcon(nameof(Vendor.No))
+
+ SortBy(nameof(Vendor.Name))" style="cursor: pointer;">
+ Name @GetSortIcon(nameof(Vendor.Name))
+
+ SortBy(nameof(Vendor.Phone))" style="cursor: pointer;">
+ Phone @GetSortIcon(nameof(Vendor.Phone))
+
+ SortBy(nameof(Vendor.Contact))" style="cursor: pointer;">
+ Contact @GetSortIcon(nameof(Vendor.Contact))
+
+ SortBy(nameof(Vendor.TaxGroup))" style="cursor: pointer;">
+ Tax Group @GetSortIcon(nameof(Vendor.TaxGroup))
+
+ SortBy(nameof(Vendor.Balance))" style="cursor: pointer;">
+ Balance @GetSortIcon(nameof(Vendor.Balance))
+
+
+
+
+
+ @foreach (var vendor in vendors)
+ {
+ SelectVendor(vendor)"
+ @ondblclick="NavigateToPurshaseVendor"
+ class="@(selectedVendor?.Id == vendor.Id ? "table-active" : "")" style="cursor: pointer;">
+
+
+
+ @vendor.No
+
+
+
+ @vendor.Name
+ @vendor.Phone
+ @vendor.Contact
+ @vendor.TaxGroup
+
+ @vendor.Balance.ToString("N2")
+
+ }
+
+
+
+
+
+
+
+
+
+
Total: @vendors.Count() vendor(s)
+
+
+ }
+
+
+@code {
+ private List? vendors;
+ private List? allVendors;
+ private Vendor? selectedVendor;
+ private bool isLoading = true;
+ private string? errorMessage;
+ private string? sortColumn;
+ private bool sortAscending = true;
+
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadVendors();
+ }
+
+ private async Task LoadVendors()
+ {
+ isLoading = true;
+ errorMessage = null;
+
+ try
+ {
+ string baseApiUrl = Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var apiUrl = baseApiUrl + "purchasing/vendors";
+ var response = await Http.GetAsync(apiUrl);
+
+ if (response.IsSuccessStatusCode)
+ {
+ allVendors = await response.Content.ReadFromJsonAsync>();
+ vendors = allVendors;
+ }
+ else
+ {
+ errorMessage = $"Failed to load vendors. Status: {response.StatusCode}";
+ }
+ }
+
+ catch (HttpRequestException ex)
+ {
+ errorMessage = $"Network error: {ex.Message}. Please ensure the API is running on port 8001.";
+ }
+
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading vendors: {ex.Message}";
+ }
+
+ finally
+ {
+ isLoading = false;
+ }
+ }
+
+ private void SelectVendor(Vendor vendor)
+ {
+ selectedVendor = vendor;
+ }
+
+ private void NavigateToPurshaseVendor()
+ {
+ if (selectedVendor != null)
+ {
+ Navigation.NavigateTo($"/purchasing/vendor/{selectedVendor.Id}", forceLoad: true);
+ }
+ }
+
+ private void SortBy(string column)
+ {
+ if (vendors == null) return;
+
+ if (sortColumn == column)
+ {
+ sortAscending = !sortAscending;
+ }
+ else
+ {
+ sortColumn = column;
+ sortAscending = true;
+ }
+
+ vendors = column switch
+ {
+ nameof(Vendor.No) => sortAscending
+ ? vendors.OrderBy(v => v.No).ToList()
+ : vendors.OrderByDescending(v => v.No).ToList(),
+
+ nameof(Vendor.Name) => sortAscending
+ ? vendors.OrderBy(v => v.Name).ToList()
+ : vendors.OrderByDescending(v => v.Name).ToList(),
+
+ nameof(Vendor.Phone) => sortAscending
+ ? vendors.OrderBy(v => v.Phone).ToList()
+ : vendors.OrderByDescending(v => v.Phone).ToList(),
+
+ nameof(Vendor.Contact) => sortAscending
+ ? vendors.OrderBy(v => v.Contact).ToList()
+ : vendors.OrderByDescending(v => v.Contact).ToList(),
+
+ nameof(Vendor.TaxGroup) => sortAscending
+ ? vendors.OrderBy(v => v.TaxGroup).ToList()
+ : vendors.OrderByDescending(v => v.TaxGroup).ToList(),
+
+ nameof(Vendor.Balance) => sortAscending
+ ? vendors.OrderBy(v => v.Balance).ToList()
+ : vendors.OrderByDescending(v => v.Balance).ToList(),
+ _ => vendors
+ };
+ }
+
+ private string GetSortIcon(string column)
+ {
+ if (sortColumn != column) return "";
+ return sortAscending ? "▲" : "▼";
+ }
+}
\ No newline at end of file
diff --git a/src/AccountGoWeb/Components/Pages/Quotations/AddSalesQuotation.razor b/src/AccountGoWeb/Components/Pages/Quotations/AddSalesQuotation.razor
new file mode 100644
index 000000000..b53578496
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Quotations/AddSalesQuotation.razor
@@ -0,0 +1,252 @@
+@using Dto.Sales
+@using Microsoft.AspNetCore.Mvc.Rendering
+@using Microsoft.EntityFrameworkCore.Metadata.Internal
+@inject HttpClient Http
+@inject IConfiguration Configuration
+@inject NavigationManager Navigation
+@inject IJSRuntime JSRuntime
+
+Add Sales Quotation
+
+
+
+
+
+
+
+
+
+
+
Customer
+
+
+ Select Customer
+ @foreach (var c in Customers)
+ {
+ @c.Name
+ }
+
+
+
+
+
+
+
Payment Term
+
+
+ Payment due within 10 days
+ Due 15th Of the Following Month
+ Cash Only
+
+
+
+
+
+
+
+
+
+
+ Item
+ Quantity
+ Amount
+ Discount
+ Unit
+
+
+
+ @if (model?.SalesQuotationLines != null)
+ {
+ @for (int i = 0; i < model.SalesQuotationLines.Count; i++)
+ {
+ var line = model.SalesQuotationLines[i];
+
+
+
+ HOA Dues
+ Car Sticker
+ Optical Mouse
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Each
+ Hour
+ Monthly
+ Pack
+
+
+
+ }
+ }
+ else
+ {
+
+ Loading...
+
+ }
+
+
+ Add Row
+
+
+
+
+
+
+
+ Save
+ Close
+
+
+
+
+@code {
+private SalesQuotation model = new SalesQuotation
+{
+ QuotationDate = DateTime.Now,
+ SalesQuotationLines = new List
+ {
+ new SalesQuotationLine
+ {
+ ItemId = 1,
+ MeasurementId = 1,
+ Quantity = 1.00m,
+ Amount = 0.00m,
+ Discount = 0.00m
+ }
+ }
+};
+
+ private List Items = new();
+ private List PaymentTerms = new();
+ private List Measurements = new();
+ private List Customers = new();
+
+ protected override async Task OnInitializedAsync()
+ {
+ var api = Configuration["ApiUrl"];
+ if (!string.IsNullOrEmpty(api) && !api.EndsWith("/"))
+ api += "/";
+
+ Customers = await Load>(api + "sales/customers");
+ PaymentTerms = await Load>(api + "common/paymentterms");
+ Items = await Load>(api + "inventory/items");
+ Measurements = await Load>(api + "common/measurements");
+ }
+
+
+ private async Task Load(string url)
+ {
+ var response = await Http.GetAsync(url);
+
+ if (!response.IsSuccessStatusCode)
+ return default!;
+
+ var json = await response.Content.ReadAsStringAsync();
+ return System.Text.Json.JsonSerializer.Deserialize(json,
+ new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
+ }
+
+
+
+ private void AddRow()
+ {
+ if (model?.SalesQuotationLines != null)
+ model.SalesQuotationLines.Add(new SalesQuotationLine
+ {
+ ItemId = 1,
+ MeasurementId = 1,
+ Quantity = 1.00m,
+ Amount = 0.00m,
+ Discount = 0.00m
+ });
+ }
+
+
+ private void Close()
+ {
+ Navigation.NavigateTo("/quotations/salesquotations", forceLoad: true);
+ }
+
+private async Task SaveQuotation()
+{
+ try
+ {
+ // Sanitize model before sending
+ if (model.CustomerId == null || model.CustomerId == 0)
+ {
+ Console.WriteLine("Error: Customer must be selected.");
+ return; // stop saving if customer not selected
+ }
+
+ // Populate dependent fields
+ model.CustomerName = Customers.FirstOrDefault(c => c.Id == model.CustomerId)?.Name;
+ model.QuotationDate = DateTime.Now;
+ model.StatusId = 0; // Draft
+ model.SalesQuoteStatus = "Draft";
+
+ // Ensure all SalesQuotationLines have valid IDs
+ foreach (var line in model.SalesQuotationLines)
+ {
+ if (line.ItemId == 0) line.ItemId = 1; // default to first item
+ if (line.MeasurementId == 0) line.MeasurementId = 1; // default to first measurement
+ if (line.Quantity == 0) line.Quantity = 1.0m;
+ if (line.Amount == 0) line.Amount = 0.0m;
+ if (line.Discount == 0) line.Discount = 0.0m;
+ }
+
+ // Serialize and log JSON
+ var json = System.Text.Json.JsonSerializer.Serialize(model,
+ new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
+ Console.WriteLine("Sending JSON to API:");
+ Console.WriteLine(json);
+
+ // Send to API
+ var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
+ var apiBase = Configuration["ApiUrl"] ?? "https://gdbapi.azurewebsites.net/api/";
+ if (!apiBase.EndsWith("/")) apiBase += "/";
+ var endpoint = apiBase + "sales/SaveQuotation";
+
+ var response = await Http.PostAsync(endpoint, content);
+
+ Console.WriteLine($"API response status: {response.StatusCode}");
+ var respContent = await response.Content.ReadAsStringAsync();
+ Console.WriteLine($"API response content: {respContent}");
+
+ if (response.IsSuccessStatusCode)
+ {
+ Console.WriteLine("Quotation saved successfully!");
+ Navigation.NavigateTo("/quotations/salesquotations", forceLoad: true);
+ }
+ else
+ {
+ Console.WriteLine("Save failed.");
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("Error saving quotation: " + ex.Message);
+ }
+}
+
+
+
+
+ public class ItemDto { public int Id { get; set; } public string Name { get; set; } = string.Empty; }
+ public class PaymentTermDto { public int Id { get; set; } public string Name { get; set; } = string.Empty; }
+ public class MeasurementDto { public int Id { get; set; } public string Name { get; set; } = string.Empty; }
+ public class CustomerDto { public int Id { get; set; } public string Name { get; set; } = string.Empty; }
+}
diff --git a/src/AccountGoWeb/Components/Pages/Quotations/Quotation.razor b/src/AccountGoWeb/Components/Pages/Quotations/Quotation.razor
new file mode 100644
index 000000000..349b6c9f4
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Quotations/Quotation.razor
@@ -0,0 +1,527 @@
+@using System.Text.Json
+@using System.Text.Json.Serialization
+@using System.Globalization
+
+
+
+Sales Quotation
+
+
+
+
+ @if (!isEditMode && Id > 0)
+ {
+
+ Edit
+
+ }
+
+
+
+ @if (loading)
+ {
+
Loading quotation...
+ }
+ else
+ {
+ @if (!string.IsNullOrEmpty(errorMessage))
+ {
+
+ Error: @errorMessage
+ errorMessage = null">
+
+ }
+
+
+
+
+
+
+
+
Customer Name
+
+
+ -- Select Customer --
+ @foreach (var c in customers)
+ {
+ @c.name
+ }
+
+
+
+
+
+
+
+
+
+
Payment Term
+
+ @if (isEditMode)
+ {
+
+ -- Select Payment Term --
+ @foreach (var pt in paymentTerms)
+ {
+ @pt.description
+ }
+
+ }
+ else
+ {
+ @SelectedPaymentTermName
+ }
+
+
+
+
+
+
Total Amount
+
@FormattedTotalAmount
+
+
+
+
+
+
+
+
+ @if (isEditMode)
+ {
+
Save
+ }
+
Close
+
+
+
+ }
+
+
+@code {
+ private class LineItem
+ {
+ public int Id { get; set; } = 0;
+ public int? ItemId { get; set; } = 0;
+ public decimal? Quantity { get; set; } = 0;
+ public decimal? Amount { get; set; } = 0;
+ public decimal? Discount { get; set; } = 0;
+ public int? MeasurementId { get; set; } = 0;
+ }
+
+ private class CustomerDto
+ {
+ public int id { get; set; }
+ public string name { get; set; } = "";
+ }
+
+ private class ItemDto
+ {
+ public int id { get; set; }
+ public string description { get; set; } = "";
+ }
+
+ private class PaymentTermDto
+ {
+ public int id { get; set; }
+ public string description { get; set; } = "";
+ }
+
+ private class MeasurementDto
+ {
+ public int id { get; set; }
+ public string description { get; set; } = "";
+ }
+
+ [SupplyParameterFromQuery(Name = "id")]
+ public int Id { get; set; }
+
+ [Inject]
+ private IHttpClientFactory ClientFactory { get; set; } = null!;
+
+ [Inject]
+ private NavigationManager Navigation { get; set; } = null!;
+
+ private bool loading = true;
+ private bool isEditMode = false;
+ private string? errorMessage = null;
+
+ private int quotationId = 0;
+ private int customerId = 0;
+ private DateTime quotationDate = DateTime.Now;
+ private int paymentTermId = 0;
+ private decimal totalAmount = 0;
+ private List lineItems = new();
+
+ private string FormattedTotalAmount => "$" + totalAmount.ToString("N2", CultureInfo.InvariantCulture);
+
+ private string SelectedPaymentTermName =>
+ paymentTerms.FirstOrDefault(pt => pt.id == paymentTermId)?.description ?? "N/A";
+
+ private List customers = new();
+ private List items = new();
+ private List paymentTerms = new();
+ private List measurements = new();
+
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadCustomers();
+ await LoadItems();
+ await LoadPaymentTerms();
+ await LoadMeasurements();
+ await LoadQuotationData(Id);
+ }
+
+ private void EnableEditMode()
+ {
+ isEditMode = true;
+ StateHasChanged();
+ }
+
+ private async Task LoadCustomers()
+ {
+ try
+ {
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+ var response = await client.GetAsync($"{apiurl}sales/customers");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var responseString = await response.Content.ReadAsStringAsync();
+ customers = JsonSerializer.Deserialize>(responseString) ?? new List();
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading customers: {ex.Message}";
+ }
+ }
+
+ private async Task LoadItems()
+ {
+ try
+ {
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+ var response = await client.GetAsync($"{apiurl}inventory/items");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var responseString = await response.Content.ReadAsStringAsync();
+ items = JsonSerializer.Deserialize>(responseString) ?? new List();
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading items: {ex.Message}";
+ }
+ }
+
+ private async Task LoadPaymentTerms()
+ {
+ try
+ {
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+ var response = await client.GetAsync($"{apiurl}common/paymentterms");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var responseString = await response.Content.ReadAsStringAsync();
+ paymentTerms = JsonSerializer.Deserialize>(responseString) ?? new List();
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading payment terms: {ex.Message}";
+ }
+ }
+
+ private async Task LoadMeasurements()
+ {
+ try
+ {
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+ var response = await client.GetAsync($"{apiurl}common/measurements");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var responseString = await response.Content.ReadAsStringAsync();
+ measurements = JsonSerializer.Deserialize>(responseString) ?? new List();
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading measurements: {ex.Message}";
+ }
+ }
+
+ private async Task LoadQuotationData(int id)
+ {
+ try
+ {
+ if (id == 0)
+ {
+ // No quotation to load
+ loading = false;
+ return;
+ }
+
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+ var response = await client.GetAsync($"{apiurl}Sales/Quotation?id={id}");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var responseString = await response.Content.ReadAsStringAsync();
+ var data = JsonSerializer.Deserialize(responseString, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ });
+
+ if (data.ValueKind == JsonValueKind.Object)
+ {
+ if (data.TryGetProperty("id", out var idElem))
+ quotationId = idElem.GetInt32();
+
+ if (data.TryGetProperty("customerId", out var custElem) && custElem.ValueKind == JsonValueKind.Number)
+ customerId = custElem.GetInt32();
+
+ if (data.TryGetProperty("quotationDate", out var dateElem))
+ {
+ if (DateTime.TryParse(dateElem.GetString(), out var date))
+ quotationDate = date;
+ }
+
+ if (data.TryGetProperty("paymentTermId", out var termElem))
+ {
+ if (termElem.ValueKind == JsonValueKind.Number)
+ paymentTermId = termElem.GetInt32();
+ }
+
+ // Parse line items
+ if (data.TryGetProperty("salesQuotationLines", out var linesElem) && linesElem.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var lineElem in linesElem.EnumerateArray())
+ {
+ var line = new LineItem
+ {
+ Id = GetJsonInt(lineElem, "id") ?? 0,
+ ItemId = GetJsonInt(lineElem, "itemId"),
+ Quantity = GetJsonDecimal(lineElem, "quantity"),
+ Amount = GetJsonDecimal(lineElem, "amount"),
+ Discount = GetJsonDecimal(lineElem, "discount"),
+ MeasurementId = GetJsonInt(lineElem, "measurementId")
+ };
+ lineItems.Add(line);
+ }
+ }
+
+ CalculateTotalAmount();
+ }
+ }
+ else
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ errorMessage = $"Failed to load quotation. Status: {response.StatusCode}. Details: {errorContent}";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading quotation: {ex.Message}";
+ }
+ finally
+ {
+ loading = false;
+ StateHasChanged();
+ }
+ }
+
+ private int? GetJsonInt(JsonElement elem, string propName)
+ {
+ if (elem.TryGetProperty(propName, out var prop))
+ {
+ if (prop.TryGetInt32(out var val))
+ return val;
+ }
+ return null;
+ }
+
+ private decimal? GetJsonDecimal(JsonElement elem, string propName)
+ {
+ if (elem.TryGetProperty(propName, out var prop))
+ {
+ if (prop.TryGetDecimal(out var val))
+ return val;
+ }
+ return null;
+ }
+
+ private void CalculateTotalAmount()
+ {
+ totalAmount = lineItems.Sum(line =>
+ {
+ var qty = line.Quantity ?? 0;
+ var amt = line.Amount ?? 0;
+ var disc = line.Discount ?? 0;
+ var total = qty * amt;
+ var discount = (disc / 100) * total;
+ return Math.Max(0, total - discount);
+ });
+ }
+
+ private void AddLineItem()
+ {
+ lineItems.Add(new LineItem { ItemId = 0, Quantity = 1, Amount = 0, Discount = 0, MeasurementId = 0 });
+ StateHasChanged();
+ }
+
+ private async Task SaveQuotation()
+ {
+ try
+ {
+ if (customerId == 0)
+ {
+ errorMessage = "Please select a customer.";
+ StateHasChanged();
+ return;
+ }
+
+ CalculateTotalAmount();
+
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+
+ var customerName = customers.FirstOrDefault(c => c.id == customerId)?.name ?? "";
+
+ var payload = new Dictionary
+ {
+ { "Id", quotationId },
+ { "CustomerId", customerId },
+ { "CustomerName", customerName },
+ { "QuotationDate", quotationDate },
+ { "PaymentTermId", paymentTermId },
+ { "StatusId", 0 },
+ { "SalesQuotationLines", lineItems.Select(line => new
+ {
+ Id = line.Id,
+ ItemId = line.ItemId,
+ Quantity = line.Quantity,
+ Amount = line.Amount,
+ Discount = line.Discount,
+ MeasurementId = line.MeasurementId
+ }).ToList()
+ }
+ };
+
+ var client = ClientFactory.CreateClient();
+ var json = JsonSerializer.Serialize(payload);
+ var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
+
+ var response = await client.PostAsync($"{apiurl}sales/SaveQuotation", content);
+
+ if (response.IsSuccessStatusCode)
+ {
+ await Task.Delay(100);
+ Navigation.NavigateTo("/quotations/salesquotations", forceLoad: true);
+ }
+ else
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ errorMessage = $"Failed to save quotation. Status: {response.StatusCode}. Response: {errorContent}";
+ StateHasChanged();
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error saving quotation: {ex.Message}";
+ StateHasChanged();
+ }
+ }
+}
diff --git a/src/AccountGoWeb/Components/Pages/Quotations/SalesQuotations.razor b/src/AccountGoWeb/Components/Pages/Quotations/SalesQuotations.razor
new file mode 100644
index 000000000..fb80ce1a1
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Quotations/SalesQuotations.razor
@@ -0,0 +1,171 @@
+@inject IHttpClientFactory HttpClientFactory
+@inject IConfiguration Configuration
+@inject NavigationManager NavigationManager
+@inject IJSRuntime JSRuntime
+@inject IJSRuntime JS
+
+@inject HttpClient Http
+
+
+Sales Quotations
+
+
+
+
+Quotations
+
+
+@if (isLoading)
+{
+
+}
+else if (hasError)
+{
+
+
+ Error! Unable to load quotations. Please try again.
+
+}
+else
+{
+
+
+
+
+ No
+ Customer
+ Date
+ Amount
+ Status
+
+
+
+ @foreach (var q in quotations)
+ {
+ OnSelectionChanged(q.id, q.statusId)"
+ @ondblclick="() => NavigateToSalesQuotation(q.id)"
+ class="quotationRow @(q.id == selectedQuotationId ? "table-active" : "")">
+ @q.no
+ @q.customerName
+ @q.quotationDate.ToShortDateString()
+ @q.amount
+ @q.salesQuoteStatus
+
+ }
+
+
+
+}
+@code {
+ private List quotations = new();
+
+ private string viewQuotationLink = "#";
+ private string newOrderLink = "#";
+ private bool isViewLinkActive = false;
+ private bool isNewOrderLinkActive = false;
+ private bool isLoading = true;
+ private bool hasError = false;
+ private int selectedQuotationId = 0;
+protected override async Task OnInitializedAsync()
+{
+ try
+ {
+ var apiBase = Configuration["ApiUrl"] ?? "https://gdbapi.azurewebsites.net/api/";
+ if (!apiBase.EndsWith("/")) apiBase += "/";
+
+ var endpoint = apiBase + "Sales/Quotations";
+
+ var response = await Http.GetAsync(endpoint);
+
+ if (response.IsSuccessStatusCode)
+ {
+ quotations = await response.Content.ReadFromJsonAsync>() ?? new List();
+ isLoading = false;
+ }
+ else
+ {
+ hasError = true;
+ isLoading = false;
+ Console.WriteLine($"Error fetching quotations: {response.StatusCode}");
+ }
+ }
+ catch (Exception ex)
+ {
+ hasError = true;
+ isLoading = false;
+ Console.WriteLine($"Exception fetching quotations: {ex.Message}");
+ }
+}
+
+
+ private void OnSelectionChanged(int id, int status)
+ {
+ viewQuotationLink = $"/quotations/quotation?id={id}";
+ isViewLinkActive = true;
+ selectedQuotationId = id;
+ if (status == 3)
+ {
+ isNewOrderLinkActive = false;
+ newOrderLink = "#";
+ }
+ else if (status == 1)
+ {
+ newOrderLink = $"/sales/salesorder?quotationId={id}";
+ isNewOrderLinkActive = true;
+ }
+ }
+
+ private void NavigateToSalesQuotation(int id)
+ {
+ NavigationManager.NavigateTo($"/quotations/quotation?id={id}", forceLoad: true);
+ }
+
+ // DTO class for deserialization
+ public class QuotationDto
+ {
+ public int id { get; set; }
+ public string no { get; set; } = "";
+ public int statusId { get; set; }
+ public string customerName { get; set; } = "";
+ public DateTime quotationDate { get; set; }
+ public decimal amount { get; set; }
+ public string salesQuoteStatus { get; set; } = "";
+ }
+}
diff --git a/src/AccountGoWeb/Components/Pages/Sales/AddReceipt.razor b/src/AccountGoWeb/Components/Pages/Sales/AddReceipt.razor
new file mode 100644
index 000000000..4a340143b
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Sales/AddReceipt.razor
@@ -0,0 +1,372 @@
+@using System.Text.Json
+@using System.Text.Json.Serialization
+@using AccountGoWeb.Models
+@using AccountGoWeb.Models.Sales
+@using Microsoft.AspNetCore.Components
+
+
+
+
+
+
+ @if (loading)
+ {
+
Loading...
+ }
+ else if (!string.IsNullOrEmpty(errorMessage))
+ {
+
+ Error: @errorMessage
+
+ }
+ else
+ {
+
+ }
+
+
+@code {
+ private bool loading = true;
+ private string errorMessage = "";
+ private bool submitAttempted = false;
+ private int? customerId = null;
+ private DateTime receiptDate = DateTime.Now;
+ private int? accountToDebitId = null;
+ private int? accountToCreditId = null;
+ private decimal amount = 0;
+
+ private List<(string Text, string Value)> customers = new();
+ private List<(string Text, string Value)> debitAccounts = new();
+ private List<(string Text, string Value)> creditAccounts = new();
+ private Dictionary customerAdvanceAccounts = new();
+ private Dictionary accountNames = new();
+
+ [Inject]
+ private IHttpClientFactory ClientFactory { get; set; } = null!;
+
+ [Inject]
+ private NavigationManager Navigation { get; set; } = null!;
+
+ protected override async Task OnInitializedAsync()
+ {
+ try
+ {
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+ client.Timeout = TimeSpan.FromSeconds(60);
+
+ // Load customers from API
+ var customersResponse = await client.GetAsync($"{apiurl}sales/customers");
+ if (customersResponse.IsSuccessStatusCode)
+ {
+ var json = await customersResponse.Content.ReadAsStringAsync();
+ var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
+ var customersData = JsonSerializer.Deserialize>(json, options);
+ if (customersData != null)
+ {
+ foreach (var customer in customersData)
+ {
+ var id = GetJsonPropertyString(customer, "id");
+ var name = GetJsonPropertyString(customer, "name");
+ var prepaymentAccountId = GetJsonPropertyString(customer, "prepaymentAccountId");
+ customers.Add((name ?? "Unknown", id));
+
+ // Store the prepayment account ID for this customer
+ if (int.TryParse(id, out var customerId_int) && int.TryParse(prepaymentAccountId, out var accountId_int))
+ {
+ customerAdvanceAccounts[customerId_int] = accountId_int;
+ }
+ }
+ }
+ }
+
+ // Load debit accounts from API (Cash & Banks)
+ var debitResponse = await client.GetAsync($"{apiurl}financials/CashBanks");
+ if (debitResponse.IsSuccessStatusCode)
+ {
+ var json = await debitResponse.Content.ReadAsStringAsync();
+ var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
+ var accountsData = JsonSerializer.Deserialize>(json, options);
+ if (accountsData != null)
+ {
+ foreach (var account in accountsData)
+ {
+ var id = GetJsonPropertyString(account, "id");
+ var name = GetJsonPropertyString(account, "name");
+ debitAccounts.Add((name ?? "Unknown", id));
+ }
+ }
+ }
+
+ // Load credit accounts from API (posting accounts only)
+ var creditResponse = await client.GetAsync($"{apiurl}common/postingaccounts");
+ if (creditResponse.IsSuccessStatusCode)
+ {
+ var json = await creditResponse.Content.ReadAsStringAsync();
+ var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
+ var accountsData = JsonSerializer.Deserialize>(json, options);
+ if (accountsData != null)
+ {
+ foreach (var account in accountsData)
+ {
+ var id = GetJsonPropertyString(account, "id");
+ var name = GetJsonPropertyString(account, "accountName");
+ if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(name))
+ {
+ creditAccounts.Add((name, id));
+ // Store account name for display
+ if (int.TryParse(id, out var accountId))
+ {
+ accountNames[accountId] = name;
+ }
+ }
+ }
+ }
+ }
+
+ // Also load all accounts to get customer advance account names
+ var allAccountsResponse = await client.GetAsync($"{apiurl}financials/accounts");
+ if (allAccountsResponse.IsSuccessStatusCode)
+ {
+ var json = await allAccountsResponse.Content.ReadAsStringAsync();
+ var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
+ var accountsData = JsonSerializer.Deserialize>(json, options);
+ if (accountsData != null)
+ {
+ foreach (var account in accountsData)
+ {
+ var id = GetJsonPropertyString(account, "id");
+ var name = GetJsonPropertyString(account, "name");
+ if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(name))
+ {
+ // Store account name for display
+ if (int.TryParse(id, out var accountId))
+ {
+ accountNames[accountId] = name;
+ }
+ }
+ }
+ }
+ }
+
+ loading = false;
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Failed to load form data: {ex.Message}";
+ loading = false;
+ }
+ }
+
+ private async Task OnCustomerChanged(ChangeEventArgs e)
+ {
+ if (e.Value == null || string.IsNullOrEmpty(e.Value.ToString()))
+ {
+ customerId = null;
+ accountToCreditId = null;
+ return;
+ }
+
+ if (int.TryParse(e.Value.ToString(), out var selectedCustomerId))
+ {
+ customerId = selectedCustomerId;
+
+ // Auto-select the customer's prepayment account
+ if (customerAdvanceAccounts.TryGetValue(selectedCustomerId, out var advanceAccountId))
+ {
+ accountToCreditId = advanceAccountId;
+ }
+ }
+ }
+ private async Task HandleSubmit()
+ {
+ submitAttempted = true;
+
+ // Validate form
+ if (string.IsNullOrEmpty(customerId?.ToString()) ||
+ string.IsNullOrEmpty(accountToDebitId?.ToString()) ||
+ !IsValidCreditAccount() ||
+ amount <= 0)
+ {
+ return;
+ }
+
+ try
+ {
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+
+ // Create an object with the form data
+ // Ensure all IDs are integers
+ var receiptData = new
+ {
+ CustomerId = customerId.HasValue ? customerId.Value : 0,
+ ReceiptDate = receiptDate,
+ AccountToDebitId = accountToDebitId.HasValue ? accountToDebitId.Value : 0,
+ AccountToCreditId = accountToCreditId.HasValue ? accountToCreditId.Value : 0,
+ Amount = amount
+ };
+
+ // Submit to API
+ var json = System.Text.Json.JsonSerializer.Serialize(receiptData);
+ var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
+
+ var response = await client.PostAsync($"{apiurl}sales/savereceipt", content);
+
+ if (response.IsSuccessStatusCode)
+ {
+ Navigation.NavigateTo("/Sales/SalesReceipts", forceLoad: true);
+ }
+ else
+ {
+ var responseContent = await response.Content.ReadAsStringAsync();
+ errorMessage = $"Failed to save customer payment. Status: {response.StatusCode}. Details: {responseContent}";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error saving customer payment: {ex.Message}";
+ }
+ }
+
+ private bool IsValidCreditAccount()
+ {
+ // Credit account must be selected
+ if (!accountToCreditId.HasValue || accountToCreditId <= 0)
+ {
+ return false;
+ }
+
+ // Credit account must match the customer's advance account
+ if (customerId.HasValue && customerAdvanceAccounts.TryGetValue(customerId.Value, out var advanceAccountId))
+ {
+ return accountToCreditId == advanceAccountId;
+ }
+
+ // If no customer selected, we can't validate
+ return false;
+ }
+
+ private string GetJsonPropertyString(JsonElement element, string propertyName)
+ {
+ if (element.TryGetProperty(propertyName, out var property))
+ {
+ if (property.ValueKind == JsonValueKind.String)
+ {
+ return property.GetString() ?? "";
+ }
+ else if (property.ValueKind == JsonValueKind.Number)
+ {
+ return property.GetInt32().ToString();
+ }
+ }
+ return "";
+ }
+}
\ No newline at end of file
diff --git a/src/AccountGoWeb/Components/Pages/Sales/Allocate.razor b/src/AccountGoWeb/Components/Pages/Sales/Allocate.razor
new file mode 100644
index 000000000..a791ffca6
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Sales/Allocate.razor
@@ -0,0 +1,419 @@
+@using System.Text.Json
+@using System.Text.Json.Serialization
+
+
+
+
+
+
+ @if (loading)
+ {
+
Loading allocation data...
+ }
+ else if (errorMessage != null)
+ {
+
+ Error: @errorMessage
+
+ }
+ else
+ {
+
+ }
+
+
+@code {
+ [Parameter]
+ public string? ReceiptId { get; set; }
+
+ [Inject]
+ private NavigationManager Navigation { get; set; } = null!;
+
+ [Inject]
+ private IHttpClientFactory ClientFactory { get; set; } = null!;
+
+ private bool loading = true;
+ private string? errorMessage = null;
+ private string? successMessage = null;
+
+ private string receiptNo = "";
+ private string customerName = "";
+ private DateTime receiptDate = DateTime.Now;
+ private decimal amount = 0;
+ private decimal remainingAmount = 0;
+ private int customerId = 0;
+
+ private List allocationLines = new();
+
+ private class AllocationLineModel
+ {
+ public int? InvoiceId { get; set; }
+ public decimal? Amount { get; set; }
+ public decimal? AllocatedAmount { get; set; }
+ public decimal? AmountToAllocate { get; set; }
+ }
+
+ protected override async Task OnInitializedAsync()
+ {
+ // Extract ReceiptId from the current URL path
+ // URL format: /Sales/Allocate/123
+ var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
+ var segments = uri.AbsolutePath.Split('/');
+
+ // Find the ID from the URL segments (last segment should be the ID)
+ if (segments.Length > 0 && int.TryParse(segments[^1], out var receiptId))
+ {
+ ReceiptId = receiptId.ToString();
+ }
+
+ // Fallback to query parameter if not found in route
+ if (string.IsNullOrEmpty(ReceiptId))
+ {
+ var queryParams = System.Web.HttpUtility.ParseQueryString(uri.Query);
+ ReceiptId = queryParams["id"];
+ }
+
+ if (!string.IsNullOrEmpty(ReceiptId))
+ {
+ await LoadAllocationData();
+ }
+ else
+ {
+ errorMessage = "Receipt ID is required.";
+ loading = false;
+ }
+ }
+
+ private async Task LoadAllocationData()
+ {
+ try
+ {
+ loading = true;
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+
+ var client = ClientFactory.CreateClient();
+ client.Timeout = TimeSpan.FromSeconds(60);
+
+ // Fetch receipt details
+ var receiptResponse = await client.GetAsync($"{apiurl}sales/salesreceipt?id={ReceiptId}");
+ if (receiptResponse.IsSuccessStatusCode)
+ {
+ var receiptString = await receiptResponse.Content.ReadAsStringAsync();
+ var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
+ var receiptJson = JsonSerializer.Deserialize(receiptString, options);
+
+ receiptNo = GetJsonProperty(receiptJson, "receiptNo");
+ customerName = GetJsonProperty(receiptJson, "customerName");
+ amount = GetJsonPropertyDecimal(receiptJson, "amount");
+ remainingAmount = GetJsonPropertyDecimal(receiptJson, "remainingAmountToAllocate");
+
+ if (receiptJson.TryGetProperty("customerId", out var custIdProp))
+ {
+ if (custIdProp.TryGetInt32(out var id))
+ customerId = id;
+ }
+
+ System.Diagnostics.Debug.WriteLine($"DEBUG: Loaded Receipt - ReceiptNo: {receiptNo}, CustomerId: {customerId}");
+
+ if (receiptJson.TryGetProperty("receiptDate", out var dateProp))
+ {
+ if (DateTime.TryParse(dateProp.GetString(), out var date))
+ receiptDate = date;
+ }
+
+ // Fetch customer invoices
+ System.Diagnostics.Debug.WriteLine($"DEBUG: Fetching invoices for customerId: {customerId}");
+ await LoadInvoicesForCustomer(customerId, client, apiurl, options);
+ }
+ else
+ {
+ errorMessage = "Failed to load payment details.";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading allocation data: {ex.Message}";
+ }
+ finally
+ {
+ loading = false;
+ }
+ }
+
+ private async Task LoadInvoicesForCustomer(int custId, HttpClient? client = null, string? apiurl = null,
+ JsonSerializerOptions? options = null)
+ {
+ if (client == null)
+ {
+ client = ClientFactory.CreateClient();
+ client.Timeout = TimeSpan.FromSeconds(60);
+ }
+
+ if (string.IsNullOrEmpty(apiurl))
+ {
+ apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ }
+
+ if (options == null)
+ {
+ options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
+ }
+
+ try
+ {
+ System.Diagnostics.Debug.WriteLine($"DEBUG: Fetching invoices for customerId: {custId}");
+ var invoiceResponse = await client.GetAsync($"{apiurl}sales/customerinvoices?id={custId}");
+ System.Diagnostics.Debug.WriteLine($"DEBUG: Invoice API Response Status: {invoiceResponse.StatusCode}");
+
+ if (invoiceResponse.IsSuccessStatusCode)
+ {
+ var invoicesString = await invoiceResponse.Content.ReadAsStringAsync();
+ System.Diagnostics.Debug.WriteLine($"DEBUG: Invoice Response: {invoicesString}");
+ var invoicesJson = JsonSerializer.Deserialize>(invoicesString, options);
+
+ System.Diagnostics.Debug.WriteLine($"DEBUG: Parsed {invoicesJson?.Count ?? 0} invoices");
+ allocationLines.Clear();
+ if (invoicesJson != null)
+ {
+ foreach (var invoice in invoicesJson)
+ {
+ var posted = false;
+ var totalAllocated = 0m;
+ var invoiceAmount = 0m;
+
+ if (invoice.TryGetProperty("posted", out var postedProp))
+ posted = postedProp.GetBoolean();
+
+ if (invoice.TryGetProperty("totalAllocatedAmount", out var allocProp))
+ totalAllocated = allocProp.GetDecimal();
+
+ if (invoice.TryGetProperty("amount", out var amountProp))
+ invoiceAmount = amountProp.GetDecimal();
+
+ // Include invoices with remaining balance
+ // TODO: Should filter for posted invoices only (posted == true), but currently allowing unposted for testing
+ // Invoices must be posted to the GL before they can be allocated.
+ // A "Post Invoice" feature should be added to SalesInvoice component.
+ if (totalAllocated < invoiceAmount)
+ {
+ var line = new AllocationLineModel
+ {
+ InvoiceId = GetJsonPropertyInt(invoice, "id"),
+ Amount = invoiceAmount,
+ AllocatedAmount = totalAllocated,
+ AmountToAllocate = null
+ };
+ allocationLines.Add(line);
+ System.Diagnostics.Debug.WriteLine($"DEBUG: Added invoice {line.InvoiceId} - Posted: {posted}, Allocated: {totalAllocated}, Amount: {invoiceAmount}");
+ }
+ else
+ {
+ System.Diagnostics.Debug.WriteLine($"DEBUG: Skipped invoice - Posted: {posted}, Allocated: {totalAllocated}, Amount: {invoiceAmount}");
+ }
+ }
+ }
+ }
+ else
+ {
+ var errorContent = await invoiceResponse.Content.ReadAsStringAsync();
+ errorMessage = "Failed to load customer invoices.";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading customer invoices: {ex.Message}";
+ }
+ }
+
+ private async Task HandleSubmit()
+ {
+ try
+ {
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+
+ var payload = new
+ {
+ CustomerId = customerId,
+ ReceiptId = int.Parse(ReceiptId ?? "0"),
+ Date = receiptDate,
+ Amount = amount,
+ RemainingAmountToAllocate = remainingAmount,
+ AllocationLines = allocationLines
+ .Where(l => l.AmountToAllocate.HasValue && l.AmountToAllocate.Value > 0)
+ .Select(l => new
+ {
+ InvoiceId = l.InvoiceId,
+ Amount = l.Amount,
+ AllocatedAmount = l.AllocatedAmount,
+ AmountToAllocate = l.AmountToAllocate
+ })
+ .ToList()
+ };
+
+ var client = ClientFactory.CreateClient();
+ var json = JsonSerializer.Serialize(payload);
+ var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
+
+ var response = await client.PostAsync($"{apiurl}sales/saveallocation", content);
+
+ if (response.IsSuccessStatusCode)
+ {
+ // Redirect to SalesReceipts page with forceLoad to refresh the MVC view
+ Navigation.NavigateTo("/Sales/SalesReceipts", forceLoad: true);
+ }
+ else
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ errorMessage = $"Failed to save allocation. Status: {response.StatusCode}";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error saving allocation: {ex.Message}";
+ }
+ }
+
+ private string GetJsonProperty(System.Text.Json.JsonElement elem, string propName)
+ {
+ if (elem.TryGetProperty(propName, out var prop))
+ return prop.GetString() ?? "";
+ return "";
+ }
+
+ private decimal GetJsonPropertyDecimal(System.Text.Json.JsonElement elem, string propName)
+ {
+ if (elem.TryGetProperty(propName, out var prop))
+ {
+ if (prop.TryGetDecimal(out var val))
+ return val;
+ }
+ return 0;
+ }
+
+ private int GetJsonPropertyInt(System.Text.Json.JsonElement elem, string propName)
+ {
+ if (elem.TryGetProperty(propName, out var prop))
+ {
+ if (prop.TryGetInt32(out var val))
+ return val;
+ }
+ return 0;
+ }
+}
\ No newline at end of file
diff --git a/src/AccountGoWeb/Components/Pages/Sales/Customer.razor b/src/AccountGoWeb/Components/Pages/Sales/Customer.razor
new file mode 100644
index 000000000..42d4b7948
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Sales/Customer.razor
@@ -0,0 +1,483 @@
+@using CustomerDto = Dto.Sales.Customer
+@using Dto.Common
+@inject HttpClient Http
+@inject NavigationManager Navigation
+@rendermode InteractiveServer
+
+@(Id == 0 ? "New" : "Edit") Customer
+
+
+
+
+
+
+ @if (Id != 0 && !isEditMode)
+ {
+
+ Edit
+
+ }
+
+
+
+ @if (isLoading)
+ {
+
+
+
+ Loading...
+
+
Loading customer...
+
+
+ }
+ else if (errorMessage != null)
+ {
+
+ }
+ else
+ {
+
+
+
+ @* General Section *@
+
+
+ @* Contact Section *@
+
+
+ @* Invoicing Section *@
+
+
+
+
+
+
+
+ Accounts Receivable
+
+
+
+ -- Select Account --
+ @foreach (var account in Accounts)
+ {
+ @account.Text
+ }
+
+
+
+
+
+ Sales
+
+
+
+ -- Select Account --
+ @foreach (var account in Accounts)
+ {
+ @account.Text
+ }
+
+
+
+
+
+ Prepayment
+
+
+
+ -- Select Account --
+ @foreach (var account in Accounts)
+ {
+ @account.Text
+ }
+
+
+
+
+
+ Discount
+
+
+
+ -- Select Account --
+ @foreach (var account in Accounts)
+ {
+ @account.Text
+ }
+
+
+
+
+
+
+
+ Tax Group
+
+
+
+ -- Select Tax Group --
+ @foreach (var taxGroup in TaxGroups)
+ {
+ @taxGroup.Text
+ }
+
+
+
+
+
+
+
+
+ @* Payment Section *@
+
+
+
+
+
+
+
+ Payment Term
+
+
+
+ -- Select Payment Term --
+ @foreach (var term in PaymentTerms)
+ {
+ @term.Text
+ }
+
+
+
+
+
+
+
+
+
+
+ @if (isEditMode || Id == 0)
+ {
+
+ Save
+
+ }
+
+ Close
+
+
+
+
+ }
+
+
+@code {
+ [Parameter]
+ public int Id { get; set; } = 0;
+
+ private CustomerDto Model { get; set; } = new();
+
+ private List Accounts { get; set; } = new();
+ private List TaxGroups { get; set; } = new();
+ private List PaymentTerms { get; set; } = new();
+
+ private bool isLoading = true;
+ private bool isEditMode = false;
+ private string? errorMessage;
+
+ protected override async Task OnInitializedAsync()
+ {
+ isLoading = true;
+
+ if (Id != 0)
+ {
+ await LoadCustomer();
+ }
+ else
+ {
+ isEditMode = true;
+ await GenerateCustomerNumber();
+ }
+
+ await LoadDropdownData();
+
+ isLoading = false;
+ }
+
+ private async Task GenerateCustomerNumber()
+ {
+ try
+ {
+ string baseApiUrl = Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var apiUrl = baseApiUrl + "sales/customers";
+ var response = await Http.GetAsync(apiUrl);
+
+ if (response.IsSuccessStatusCode)
+ {
+ var customers = await response.Content.ReadFromJsonAsync>();
+ if (customers != null && customers.Any())
+ {
+ // Find the highest customer number
+ var maxNo = customers
+ .Select(c => int.TryParse(c.No, out int num) ? num : 0)
+ .DefaultIfEmpty(0)
+ .Max();
+
+ Model.No = (maxNo + 1).ToString();
+ }
+ else
+ {
+ Model.No = "1";
+ }
+ }
+ }
+ catch (Exception)
+ {
+ Model.No = "1";
+ }
+ }
+
+ private async Task LoadCustomer()
+ {
+ try
+ {
+ string baseApiUrl = Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var apiUrl = $"{baseApiUrl}sales/customer?id={Id}";
+ var response = await Http.GetAsync(apiUrl);
+
+ if (response.IsSuccessStatusCode)
+ {
+ Model = await response.Content.ReadFromJsonAsync() ?? new();
+ }
+ else
+ {
+ errorMessage = $"Failed to load customer. Status: {response.StatusCode}";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading customer: {ex.Message}";
+ }
+ }
+
+ private async Task LoadDropdownData()
+ {
+ try
+ {
+ string baseApiUrl = Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+
+ // Load accounts
+ var accountsResponse = await Http.GetAsync(baseApiUrl + "common/postingaccounts");
+ if (accountsResponse.IsSuccessStatusCode)
+ {
+ var accounts = await accountsResponse.Content.ReadFromJsonAsync>();
+ Accounts = accounts?.Select(a => new SelectListItem { Value = a.Id.ToString(), Text = a.AccountName! }).ToList() ?? new();
+ }
+
+ // Load tax groups
+ var taxGroupsResponse = await Http.GetAsync(baseApiUrl + "tax/taxgroups");
+ if (taxGroupsResponse.IsSuccessStatusCode)
+ {
+ var taxGroups = await taxGroupsResponse.Content.ReadFromJsonAsync>();
+ TaxGroups = taxGroups?.Select(tg => new SelectListItem { Value = tg.Id.ToString(), Text = tg.Description! }).ToList() ?? new();
+ }
+
+ // Load payment terms
+ var paymentTermsResponse = await Http.GetAsync(baseApiUrl + "common/paymentterms");
+ if (paymentTermsResponse.IsSuccessStatusCode)
+ {
+ var paymentTerms = await paymentTermsResponse.Content.ReadFromJsonAsync>();
+ PaymentTerms = paymentTerms?.Select(pt => new SelectListItem { Value = pt.Id.ToString(), Text = pt.Description }).ToList() ?? new();
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading form data: {ex.Message}";
+ }
+ }
+
+ private async Task HandleValidSubmit()
+ {
+ try
+ {
+ string baseApiUrl = Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var apiUrl = baseApiUrl + "sales/savecustomer";
+
+ var response = await Http.PostAsJsonAsync(apiUrl, Model);
+
+ if (response.IsSuccessStatusCode)
+ {
+ Navigation.NavigateTo("/sales/customers", forceLoad: true);
+ }
+ else
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ errorMessage = $"Failed to save customer. Status: {response.StatusCode}. {errorContent}";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error saving customer: {ex.Message}";
+ }
+ }
+
+ private void EnableEditMode()
+ {
+ isEditMode = true;
+ }
+
+ private void NavigateToCustomerList()
+ {
+ Navigation.NavigateTo("/sales/customers", forceLoad: true);
+ }
+
+ private void NavigateToAddContact()
+ {
+ Navigation.NavigateTo($"/contact/contact?partyId={Model.Id}&partyType=1", forceLoad: true);
+ }
+
+ private void NavigateToContacts()
+ {
+ Navigation.NavigateTo($"/contact/contacts?partyId={Model.Id}&partyType=1", forceLoad: true);
+ }
+
+ public class SelectListItem
+ {
+ public string Value { get; set; } = string.Empty;
+ public string Text { get; set; } = string.Empty;
+ }
+
+ public class PaymentTermResponse
+ {
+ public int Id { get; set; }
+ public string Description { get; set; } = string.Empty;
+ }
+}
diff --git a/src/AccountGoWeb/Components/Pages/Sales/Customers.razor b/src/AccountGoWeb/Components/Pages/Sales/Customers.razor
new file mode 100644
index 000000000..f58be249d
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Sales/Customers.razor
@@ -0,0 +1,288 @@
+@using CustomerDto = Dto.Sales.Customer
+@inject HttpClient Http
+@inject NavigationManager Navigation
+@rendermode InteractiveServer
+
+Customers
+
+
+
+
+
+
+ New Customer
+
+ @if (selectedCustomer != null)
+ {
+
+ View
+
+ }
+
+
+
+ @if (isLoading)
+ {
+
+
+
+ Loading...
+
+
Loading customers...
+
+
+ }
+ else if (errorMessage != null)
+ {
+
+ }
+ else if (customers == null || !customers.Any())
+ {
+
+
+
+ No customers found.
+
+
+
+ }
+ else
+ {
+
+
+
+
+
+
+ SortBy(nameof(CustomerDto.No))" style="cursor: pointer;">
+ No @GetSortIcon(nameof(CustomerDto.No))
+
+ SortBy(nameof(CustomerDto.Name))" style="cursor: pointer;">
+ Name @GetSortIcon(nameof(CustomerDto.Name))
+
+ SortBy(nameof(CustomerDto.Phone))" style="cursor: pointer;">
+ Phone @GetSortIcon(nameof(CustomerDto.Phone))
+
+ SortBy(nameof(CustomerDto.Contact))" style="cursor: pointer;">
+ Contact @GetSortIcon(nameof(CustomerDto.Contact))
+
+ SortBy(nameof(CustomerDto.TaxGroup))" style="cursor: pointer;">
+ Tax Group @GetSortIcon(nameof(CustomerDto.TaxGroup))
+
+ SortBy(nameof(CustomerDto.Balance))" style="cursor: pointer;">
+ Balance @GetSortIcon(nameof(CustomerDto.Balance))
+
+
+
+
+ @foreach (var customer in customers)
+ {
+ SelectCustomer(customer)"
+ @ondblclick="() => NavigateToViewCustomer()"
+ style="cursor: pointer; @(selectedCustomer?.Id == customer.Id ? "background-color: #007bff33; border-left: 3px solid #007bff;" : "")">
+
+
+ @customer.No
+
+
+ @customer.Name
+ @customer.Phone
+ @customer.Contact
+ @customer.TaxGroup
+ @customer.Balance.ToString("N2")
+
+ }
+
+
+
+
+
+ }
+
+@if (isLoading)
+{
+
+}
+else if (errorMessage != null)
+{
+
+
+ Error! @errorMessage
+
+}
+else if (customers == null || !customers.Any())
+{
+
+ No customers found.
+
+}
+else
+{
+
+
+
+
+ SortBy(nameof(CustomerDto.No))" style="cursor: pointer;">
+ No @GetSortIcon(nameof(CustomerDto.No))
+
+ SortBy(nameof(CustomerDto.Name))" style="cursor: pointer;">
+ Name @GetSortIcon(nameof(CustomerDto.Name))
+
+ SortBy(nameof(CustomerDto.Phone))" style="cursor: pointer;">
+ Phone @GetSortIcon(nameof(CustomerDto.Phone))
+
+ SortBy(nameof(CustomerDto.Contact))" style="cursor: pointer;">
+ Contact @GetSortIcon(nameof(CustomerDto.Contact))
+
+ SortBy(nameof(CustomerDto.TaxGroup))" style="cursor: pointer;">
+ Tax Group @GetSortIcon(nameof(CustomerDto.TaxGroup))
+
+ SortBy(nameof(CustomerDto.Balance))" style="cursor: pointer;">
+ Balance @GetSortIcon(nameof(CustomerDto.Balance))
+
+
+
+
+ @foreach (var customer in customers)
+ {
+ SelectCustomer(customer)" class="customerRow @(selectedCustomer?.Id == customer.Id ? "table-active" : "")" style="cursor: pointer;">
+
+
+ @customer.No
+
+
+ @customer.Name
+ @customer.Phone
+ @customer.Contact
+ @customer.TaxGroup
+ @customer.Balance.ToString("N2")
+
+ }
+
+
+
+
Total: @customers.Count() customer(s)
+
+}
+
+@code {
+ private List? customers;
+ private List? allCustomers;
+ private CustomerDto? selectedCustomer;
+ private bool isLoading = true;
+ private string? errorMessage;
+ private string? sortColumn;
+ private bool sortAscending = true;
+
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadCustomers();
+ }
+
+ private async Task LoadCustomers()
+ {
+ isLoading = true;
+ errorMessage = null;
+
+ try
+ {
+ string baseApiUrl = Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var apiUrl = baseApiUrl + "sales/customers";
+ var response = await Http.GetAsync(apiUrl);
+
+ if (response.IsSuccessStatusCode)
+ {
+ allCustomers = await response.Content.ReadFromJsonAsync>();
+ customers = allCustomers;
+ }
+ else
+ {
+ errorMessage = $"Failed to load customers. Status: {response.StatusCode}";
+ }
+ }
+ catch (HttpRequestException ex)
+ {
+ errorMessage = $"Network error: {ex.Message}. Please ensure the API is running on port 8001.";
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading customers: {ex.Message}";
+ }
+ finally
+ {
+ isLoading = false;
+ }
+ }
+
+ private void SelectCustomer(CustomerDto customer)
+ {
+ selectedCustomer = customer;
+ }
+
+ private void NavigateToNewCustomer()
+ {
+ Navigation.NavigateTo("/sales/customer", forceLoad: true);
+ }
+
+ private void NavigateToViewCustomer()
+ {
+ if (selectedCustomer != null)
+ {
+ Navigation.NavigateTo($"/sales/customer/{selectedCustomer.Id}", forceLoad: true);
+ }
+ }
+
+ private void SortBy(string column)
+ {
+ if (customers == null) return;
+
+ if (sortColumn == column)
+ {
+ sortAscending = !sortAscending;
+ }
+ else
+ {
+ sortColumn = column;
+ sortAscending = true;
+ }
+
+ customers = column switch
+ {
+ nameof(CustomerDto.No) => sortAscending
+ ? customers.OrderBy(c => c.No).ToList()
+ : customers.OrderByDescending(c => c.No).ToList(),
+ nameof(CustomerDto.Name) => sortAscending
+ ? customers.OrderBy(c => c.Name).ToList()
+ : customers.OrderByDescending(c => c.Name).ToList(),
+ nameof(CustomerDto.Phone) => sortAscending
+ ? customers.OrderBy(c => c.Phone).ToList()
+ : customers.OrderByDescending(c => c.Phone).ToList(),
+ nameof(CustomerDto.Contact) => sortAscending
+ ? customers.OrderBy(c => c.Contact).ToList()
+ : customers.OrderByDescending(c => c.Contact).ToList(),
+ nameof(CustomerDto.TaxGroup) => sortAscending
+ ? customers.OrderBy(c => c.TaxGroup).ToList()
+ : customers.OrderByDescending(c => c.TaxGroup).ToList(),
+ nameof(CustomerDto.Balance) => sortAscending
+ ? customers.OrderBy(c => c.Balance).ToList()
+ : customers.OrderByDescending(c => c.Balance).ToList(),
+ _ => customers
+ };
+ }
+
+ private string GetSortIcon(string column)
+ {
+ if (sortColumn != column) return "";
+ return sortAscending ? "▲" : "▼";
+ }
+}
diff --git a/src/AccountGoWeb/Components/Pages/Sales/NewSalesOrder.razor b/src/AccountGoWeb/Components/Pages/Sales/NewSalesOrder.razor
new file mode 100644
index 000000000..3acce3772
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Sales/NewSalesOrder.razor
@@ -0,0 +1,461 @@
+@using System.Text.Json
+@using System.Text.Json.Serialization
+
+
+
+
+
+
+
+
+ @if (!isNew)
+ {
+ editMode = !editMode">
+ @(editMode ? "Cancel" : "Edit")
+
+ }
+
+
+
+ @if (loading)
+ {
+
Loading...
+ }
+ else if (errorMessage != null)
+ {
+
+ Error: @errorMessage
+
+ }
+ else if (order == null)
+ {
+
Sales order not found.
+ }
+ else
+ {
+
+
+
+
+
+
+ Customer
+
+ -- Select Customer --
+ @foreach (var customer in customers)
+ {
+ @customer.name
+ }
+
+
+
+ Payment Term
+
+ -- Select Payment Term --
+ @foreach (var term in paymentTerms)
+ {
+ @term.description
+ }
+
+
+
+
+
+
+
Line Items
+
+
+ @if (editMode)
+ {
+
Add Line Item
+ }
+
+
+
+
+ @if (editMode)
+ {
+
Save Order
+
Cancel
+ }
+
+
+
+ }
+
+
+@code {
+ private class LineItem
+ {
+ public int ItemId { get; set; } = 1;
+ public string ItemDescription { get; set; } = "";
+ public int MeasurementId { get; set; } = 1;
+ public decimal Quantity { get; set; } = 1;
+ public decimal Amount { get; set; } = 0;
+ public decimal Discount { get; set; } = 0;
+ }
+
+ private class Customer
+ {
+ public int id { get; set; }
+ public string name { get; set; } = "";
+ }
+
+ private class Item
+ {
+ public int id { get; set; }
+ public string description { get; set; } = "";
+ }
+
+ private class PaymentTerm
+ {
+ public int id { get; set; }
+ public string description { get; set; } = "";
+ }
+
+ private class Measurement
+ {
+ public int id { get; set; }
+ public string description { get; set; } = "";
+ }
+
+ [Parameter]
+ public string Id { get; set; } = null!;
+
+ [Inject]
+ private IHttpClientFactory ClientFactory { get; set; } = null!;
+
+ [Inject]
+ private NavigationManager Navigation { get; set; } = null!;
+
+ private Dictionary order = new();
+ private bool loading = true;
+ private bool editMode = false;
+ private bool isNew = false;
+ private string? errorMessage = null;
+
+ private string orderNo = "";
+ private int customerId = 1;
+ private string customerName = "";
+ private DateTime orderDate = DateTime.Now;
+ private string referenceNo = "";
+ private int paymentTermId = 1;
+ private decimal totalAmount = 0;
+ private List lineItems = new();
+
+ private List customers = new();
+ private List- items = new();
+ private List
paymentTerms = new();
+ private List measurements = new();
+
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadCustomers();
+ await LoadItems();
+ await LoadPaymentTerms();
+ await LoadMeasurements();
+
+ isNew = Id == "new" || string.IsNullOrEmpty(Id);
+
+ if (isNew)
+ {
+ order = new Dictionary();
+ orderNo = new Random().Next(1, 99999).ToString();
+ orderDate = DateTime.Now;
+ lineItems = new List { new LineItem() };
+ editMode = true;
+ loading = false;
+ }
+ else
+ {
+ Navigation.NavigateTo("/Sales/AddSalesOrder");
+ }
+ }
+
+ private async Task LoadCustomers()
+ {
+ try
+ {
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+ var response = await client.GetAsync($"{apiurl}sales/customers");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var responseString = await response.Content.ReadAsStringAsync();
+ customers = JsonSerializer.Deserialize>(responseString) ?? new List();
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading customers: {ex.Message}";
+ }
+ }
+
+ private async Task LoadItems()
+ {
+ try
+ {
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+ var response = await client.GetAsync($"{apiurl}inventory/items");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var responseString = await response.Content.ReadAsStringAsync();
+ items = JsonSerializer.Deserialize>(responseString) ?? new List- ();
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading items: {ex.Message}";
+ }
+ }
+
+ private async Task LoadPaymentTerms()
+ {
+ try
+ {
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+ var response = await client.GetAsync($"{apiurl}common/paymentterms");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var responseString = await response.Content.ReadAsStringAsync();
+ paymentTerms = JsonSerializer.Deserialize
>(responseString) ?? new List();
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading payment terms: {ex.Message}";
+ }
+ }
+
+ private async Task LoadMeasurements()
+ {
+ try
+ {
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+ var response = await client.GetAsync($"{apiurl}common/measurements");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var responseString = await response.Content.ReadAsStringAsync();
+ measurements = JsonSerializer.Deserialize>(responseString) ?? new List();
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading units: {ex.Message}";
+ }
+ }
+
+ private void MapJsonToProperties(System.Text.Json.JsonElement data)
+ {
+ orderNo = GetJsonProperty(data, "no");
+ customerName = GetJsonProperty(data, "customerName");
+ referenceNo = GetJsonProperty(data, "referenceNo");
+
+ if (data.TryGetProperty("orderDate", out var dateElem))
+ {
+ if (DateTime.TryParse(dateElem.GetString(), out var date))
+ orderDate = date;
+ }
+
+ lineItems = new();
+ if (data.TryGetProperty("salesOrderLines", out var linesElem) && linesElem.ValueKind ==
+ System.Text.Json.JsonValueKind.Array)
+ {
+ foreach (var lineElem in linesElem.EnumerateArray())
+ {
+ var line = new LineItem
+ {
+ ItemDescription = GetJsonProperty(lineElem, "itemDescription"),
+ Quantity = GetJsonPropertyDecimal(lineElem, "quantity"),
+ Amount = GetJsonPropertyDecimal(lineElem, "amount"),
+ Discount = GetJsonPropertyDecimal(lineElem, "discount")
+ };
+ lineItems.Add(line);
+ }
+ }
+
+ CalculateTotalAmount();
+ }
+
+ private string GetJsonProperty(System.Text.Json.JsonElement elem, string propName)
+ {
+ if (elem.TryGetProperty(propName, out var prop))
+ {
+ return prop.GetString() ?? "";
+ }
+ return "";
+ }
+
+ private decimal GetJsonPropertyDecimal(System.Text.Json.JsonElement elem, string propName)
+ {
+ if (elem.TryGetProperty(propName, out var prop))
+ {
+ if (prop.TryGetDecimal(out var val))
+ return val;
+ }
+ return 0;
+ }
+
+ private void AddLineItem()
+ {
+ lineItems.Add(new LineItem());
+ CalculateTotalAmount();
+ StateHasChanged();
+ }
+
+ private void RemoveLineItem(int index)
+ {
+ if (index >= 0 && index < lineItems.Count)
+ {
+ lineItems.RemoveAt(index);
+ }
+ CalculateTotalAmount();
+ StateHasChanged();
+ }
+
+ private void CalculateTotalAmount()
+ {
+ totalAmount = lineItems.Sum(line =>
+ {
+ var qty = line.Quantity;
+ var amt = line.Amount;
+ var disc = line.Discount;
+ var total = qty * amt;
+ var discount = (disc / 100) * total;
+ return total - discount;
+ });
+ }
+
+ private async Task SaveSalesOrder()
+ {
+ try
+ {
+ CalculateTotalAmount();
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+
+ var payload = new
+ {
+ CustomerId = customerId,
+ OrderDate = orderDate,
+ PaymentTermId = paymentTermId,
+ ReferenceNo = referenceNo,
+ SalesOrderLines = lineItems.Select(line => new
+ {
+ ItemId = line.ItemId,
+ MeasurementId = line.MeasurementId,
+ Quantity = line.Quantity,
+ Amount = line.Amount,
+ Discount = line.Discount
+ }).ToList()
+ };
+
+ var client = ClientFactory.CreateClient();
+ var json = JsonSerializer.Serialize(payload);
+ var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
+
+ var response = await client.PostAsync($"{apiurl}sales/addsalesorder", content);
+
+ if (response.IsSuccessStatusCode)
+ {
+ editMode = false;
+ errorMessage = null;
+ await Task.Delay(100);
+ Navigation.NavigateTo("/Sales/SalesOrders", forceLoad: true);
+ }
+ else
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ errorMessage = $"Failed to save sales order. Status: {response.StatusCode}. Response: {errorContent}";
+ StateHasChanged();
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error saving sales order: {ex.Message}";
+ StateHasChanged();
+ }
+ }
+
+ private void CancelSalesOrder()
+ {
+ Navigation.NavigateTo("/Sales/SalesOrders");
+ }
+}
diff --git a/src/AccountGoWeb/Components/Pages/Sales/SalesInvoice.razor b/src/AccountGoWeb/Components/Pages/Sales/SalesInvoice.razor
new file mode 100644
index 000000000..70bf7eabd
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Sales/SalesInvoice.razor
@@ -0,0 +1,539 @@
+@using System.Text.Json
+@using System.Text.Json.Serialization
+@using System.Globalization
+
+
+
+
+
+
+ @if (loading)
+ {
+
Loading invoice...
+ }
+ else
+ {
+ @if (!string.IsNullOrEmpty(errorMessage))
+ {
+
+ Error: @errorMessage
+ errorMessage = null">
+
+ }
+
+
+
+
+
+
+
+
+
+ Customer
+
+ -- Select Customer --
+ @foreach (var customer in customers)
+ {
+ @customer.name
+ }
+
+
+
+ Payment Term
+
+ -- Select Payment Term --
+ @foreach (var term in paymentTerms)
+ {
+ @term.description
+ }
+
+
+
+
+
+
+
Line Items
+
+
+
+
+
+ Add Row
+
+
+
+
+
+
+
+
+ }
+
+
+@code {
+ private class LineItem
+ {
+ public int Id { get; set; } = 0; // SalesOrderLine.Id when creating from order
+ public int? ItemId { get; set; } = 0;
+ public decimal? Quantity { get; set; } = 0;
+ public decimal? Amount { get; set; } = 0;
+ public decimal? Discount { get; set; } = 0;
+ public int? MeasurementId { get; set; } = 0;
+ }
+
+ private class Customer
+ {
+ public int id { get; set; }
+ public string name { get; set; } = "";
+ }
+
+ private class Item
+ {
+ public int id { get; set; }
+ public string description { get; set; } = "";
+ }
+
+ private class PaymentTerm
+ {
+ public int id { get; set; }
+ public string description { get; set; } = "";
+ }
+
+ private class Measurement
+ {
+ public int id { get; set; }
+ public string description { get; set; } = "";
+ }
+
+ [SupplyParameterFromQuery(Name = "id")]
+ public int Id { get; set; }
+
+ [SupplyParameterFromQuery(Name = "orderId")]
+ public int? OrderId { get; set; }
+
+ [Inject]
+ private IHttpClientFactory ClientFactory { get; set; } = null!;
+
+ [Inject]
+ private NavigationManager Navigation { get; set; } = null!;
+
+ private bool loading = true;
+ private string? errorMessage = null;
+
+ private int invoiceId = 0;
+ private DateTime invoiceDate = DateTime.Now;
+ private int customerId = 0;
+ private int paymentTermId = 0;
+ private decimal totalAmount = 0;
+ private List lineItems = new();
+
+ private string FormattedTotalAmount => "$" + totalAmount.ToString("N2", CultureInfo.InvariantCulture);
+
+ private List customers = new();
+ private List- items = new();
+ private List
paymentTerms = new();
+ private List measurements = new();
+
+ protected override async Task OnInitializedAsync()
+ {
+ // Use OrderId if provided, otherwise use Id
+ var finalId = OrderId ?? Id;
+
+ await LoadCustomers();
+ await LoadItems();
+ await LoadPaymentTerms();
+ await LoadMeasurements();
+ await LoadInvoiceData(finalId);
+ }
+
+ private async Task LoadCustomers()
+ {
+ try
+ {
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+ var response = await client.GetAsync($"{apiurl}sales/customers");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var responseString = await response.Content.ReadAsStringAsync();
+ customers = JsonSerializer.Deserialize>(responseString) ?? new List();
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading customers: {ex.Message}";
+ }
+ }
+
+ private async Task LoadItems()
+ {
+ try
+ {
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+ var response = await client.GetAsync($"{apiurl}inventory/items");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var responseString = await response.Content.ReadAsStringAsync();
+ items = JsonSerializer.Deserialize>(responseString) ?? new List- ();
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading items: {ex.Message}";
+ }
+ }
+
+ private async Task LoadPaymentTerms()
+ {
+ try
+ {
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+ var response = await client.GetAsync($"{apiurl}common/paymentterms");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var responseString = await response.Content.ReadAsStringAsync();
+ paymentTerms = JsonSerializer.Deserialize
>(responseString) ?? new List();
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading payment terms: {ex.Message}";
+ }
+ }
+
+ private async Task LoadMeasurements()
+ {
+ try
+ {
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+ var response = await client.GetAsync($"{apiurl}common/measurements");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var responseString = await response.Content.ReadAsStringAsync();
+ measurements = JsonSerializer.Deserialize>(responseString) ?? new List();
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading units: {ex.Message}";
+ }
+ }
+
+ private async Task LoadInvoiceData(int saleOrderId)
+ {
+ try
+ {
+ // If saleOrderId is 0, we're creating a brand new invoice, no API call needed
+ if (saleOrderId == 0)
+ {
+ lineItems.Add(new LineItem { ItemId = 0, Quantity = 1, Amount = 0, Discount = 0, MeasurementId = 0 });
+ return;
+ }
+
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+
+ // If we have an orderId, load from the sales order instead
+ string endpoint = OrderId.HasValue && OrderId.Value > 0
+ ? $"{apiurl}sales/SalesOrder?id={saleOrderId}" // Load from order
+ : $"{apiurl}sales/SalesInvoice?id={saleOrderId}"; // Load existing invoice
+
+ var response = await client.GetAsync(endpoint);
+
+ if (response.IsSuccessStatusCode)
+ {
+ var responseString = await response.Content.ReadAsStringAsync();
+ var invoiceData = JsonSerializer.Deserialize(responseString, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ });
+
+ if (invoiceData.ValueKind == JsonValueKind.Object)
+ {
+ // Parse invoice/order data
+ if (invoiceData.TryGetProperty("id", out var idElem))
+ invoiceId = OrderId.HasValue ? 0 : idElem.GetInt32(); // New invoice gets id=0
+
+ if (invoiceData.TryGetProperty("customerId", out var custElem))
+ customerId = custElem.GetInt32();
+
+ // For orders, use orderDate; for invoices, use invoiceDate
+ if (invoiceData.TryGetProperty("invoiceDate", out var invDateElem))
+ {
+ if (DateTime.TryParse(invDateElem.GetString(), out var date))
+ invoiceDate = date;
+ }
+ else if (invoiceData.TryGetProperty("orderDate", out var ordDateElem))
+ {
+ if (DateTime.TryParse(ordDateElem.GetString(), out var date))
+ invoiceDate = date;
+ }
+
+ if (invoiceData.TryGetProperty("paymentTermId", out var termElem))
+ {
+ if (termElem.ValueKind == JsonValueKind.Number)
+ paymentTermId = termElem.GetInt32();
+ else if (termElem.ValueKind == JsonValueKind.Null)
+ paymentTermId = 0;
+ }
+
+ // Parse line items - works for both orders and invoices
+ var linesProperty = OrderId.HasValue ? "salesOrderLines" : "salesInvoiceLines";
+ if (invoiceData.TryGetProperty(linesProperty, out var linesElem) && linesElem.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var lineElem in linesElem.EnumerateArray())
+ {
+ var line = new LineItem
+ {
+ Id = GetJsonInt(lineElem, "id") ?? 0, // Capture the order line ID!
+ ItemId = GetJsonInt(lineElem, "itemId"),
+ Quantity = GetJsonDecimal(lineElem, "quantity"),
+ Amount = GetJsonDecimal(lineElem, "amount"),
+ Discount = GetJsonDecimal(lineElem, "discount"),
+ MeasurementId = GetJsonInt(lineElem, "measurementId")
+ };
+ lineItems.Add(line);
+ }
+ }
+
+ CalculateTotalAmount();
+ }
+ }
+ else
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ errorMessage = $"Failed to load data. Status: {response.StatusCode}. Details: {errorContent}";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading data: {ex.Message}";
+ }
+ finally
+ {
+ loading = false;
+ StateHasChanged();
+ }
+ }
+
+ private int? GetJsonInt(JsonElement elem, string propName)
+ {
+ if (elem.TryGetProperty(propName, out var prop))
+ {
+ if (prop.TryGetInt32(out var val))
+ return val;
+ }
+ return null;
+ }
+
+ private decimal? GetJsonDecimal(JsonElement elem, string propName)
+ {
+ if (elem.TryGetProperty(propName, out var prop))
+ {
+ if (prop.TryGetDecimal(out var val))
+ return val;
+ }
+ return null;
+ }
+
+ private void CalculateTotalAmount()
+ {
+ totalAmount = lineItems.Sum(line =>
+ {
+ var qty = line.Quantity ?? 0;
+ var amt = line.Amount ?? 0;
+ var disc = line.Discount ?? 0;
+ var total = qty * amt;
+ var discount = (disc / 100) * total;
+ return total - discount;
+ });
+ }
+
+ private void AddLineItem()
+ {
+ lineItems.Add(new LineItem { ItemId = 0, Quantity = 1, Amount = 0, Discount = 0, MeasurementId = 0 });
+ StateHasChanged();
+ }
+
+ private async Task SaveSalesInvoice()
+ {
+ try
+ {
+ // Validate required fields
+ if (customerId == 0)
+ {
+ errorMessage = "Please select a customer";
+ StateHasChanged();
+ return;
+ }
+
+ if (paymentTermId == 0)
+ {
+ errorMessage = "Please select a payment term";
+ StateHasChanged();
+ return;
+ }
+
+ // Validate line items
+ if (lineItems == null || lineItems.Count == 0)
+ {
+ errorMessage = "Please add at least one line item";
+ StateHasChanged();
+ return;
+ }
+
+ // Check if all line items have valid data
+ var invalidLines = lineItems.Where(l => l.ItemId == 0 || l.MeasurementId == 0 || l.Quantity == 0 || l.Amount ==
+ 0).ToList();
+ if (invalidLines.Any())
+ {
+ errorMessage = "All line items must have Item, Unit, Quantity, and Amount selected";
+ StateHasChanged();
+ return;
+ }
+
+ CalculateTotalAmount();
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+
+ // Use OrderId if provided, otherwise use Id
+ var finalId = OrderId ?? Id;
+
+ // Build payload conditionally - only include FromSalesOrderId if we're creating from an existing order
+ var payload = new Dictionary
+{
+{ "Id", invoiceId },
+{ "CustomerId", customerId },
+{ "InvoiceDate", invoiceDate },
+{ "PaymentTermId", paymentTermId },
+{ "SalesInvoiceLines", lineItems.Select(line => new
+{
+Id = line.Id, // Include the SalesOrderLine.Id to prevent duplication
+ItemId = line.ItemId,
+Quantity = line.Quantity,
+Amount = line.Amount,
+Discount = line.Discount,
+MeasurementId = line.MeasurementId
+}).ToList() }
+};
+
+ // Only include FromSalesOrderId if we're creating from an existing order
+ if (finalId > 0)
+ {
+ payload["FromSalesOrderId"] = finalId;
+ }
+
+ var client = ClientFactory.CreateClient();
+ var json = JsonSerializer.Serialize(payload);
+ var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
+
+ var response = await client.PostAsync($"{apiurl}sales/SaveSalesInvoice", content);
+
+ if (response.IsSuccessStatusCode)
+ {
+ await Task.Delay(100);
+ Navigation.NavigateTo("/Sales/SalesInvoices", forceLoad: true);
+ }
+ else
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ errorMessage = $"Failed to save invoice. Status: {response.StatusCode}. Response: {errorContent}";
+ StateHasChanged();
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error saving invoice: {ex.Message}";
+ StateHasChanged();
+ }
+ }
+}
diff --git a/src/AccountGoWeb/Components/Pages/Sales/SalesOrder.razor b/src/AccountGoWeb/Components/Pages/Sales/SalesOrder.razor
new file mode 100644
index 000000000..e607ec80e
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Sales/SalesOrder.razor
@@ -0,0 +1,706 @@
+@using System.Reflection
+@using SalesOrderDto = Dto.Sales.SalesOrder
+@using SalesOrderLineDto = Dto.Sales.SalesOrderLine
+@using SalesQuotationDto = Dto.Sales.SalesQuotation
+@using CustomerDto = Dto.Sales.Customer
+@using System.Net.Http.Json
+@using ItemDto = Dto.Inventory.Item
+@using MeasurementDto = Dto.Inventory.Measurement
+@inject HttpClient Http
+@inject NavigationManager Navigation
+@(IsNewOrder ? "Add Sales Order" : "Sales Order")
+
+
+ @if (isLoading)
+ {
+
Loading sales order...
+ }
+ else if (!string.IsNullOrWhiteSpace(errorMessage))
+ {
+
@errorMessage
+ }
+ else
+ {
+
+
@(IsNewOrder ? "Add Sales Order" : "Sales Order")
+ @if (!IsNewOrder)
+ {
+ @GetStatusLabel(salesOrder.StatusId)
+ }
+
+
+ @if (validationErrors.Count > 0)
+ {
+
+
+ @foreach (var error in validationErrors)
+ {
+ @error
+ }
+
+
+ }
+
+
+
+
+ @if (isEditMode)
+ {
+
+ Save Order
+
+ }
+ Cancel
+
+ }
+
+
+@code {
+ // Route parameter used when opening an existing sales order.
+ [Parameter]
+ public int OrderId { get; set; }
+
+ // Query-string parameter used when creating a sales order from a quotation.
+ [Parameter]
+ [SupplyParameterFromQuery]
+ public int QuotationId { get; set; }
+
+ // Main DTO sent to and from the API.
+ // This holds the header fields and the line item collection.
+ private SalesOrderDto salesOrder = new() { SalesOrderLines = new List() };
+
+ // UI wrapper for line items.
+ // Each row gets a stable Guid key so Blazor can track add/remove operations correctly.
+ private List lineRows = new();
+
+ // Reference data used by dropdowns in the form.
+ private List customers = new();
+ private List paymentTerms = new();
+ private List items = new();
+ private List measurements = new();
+
+ // Validation messages shown at the top of the form.
+ private List validationErrors = new();
+
+ // Generic page error message used for failed API calls.
+ private string? errorMessage;
+
+ // Loading and saving flags used to control UI state.
+ private bool isLoading = true;
+ private bool isSaving;
+ private bool isEditMode;
+
+ // Bound to the date input so the user can edit the order date.
+ private DateTime orderDateValue = DateTime.Today;
+
+ // Base URL for backend API calls.
+ private string baseApiUrl = string.Empty;
+
+ // True when the page is creating a new order rather than viewing an existing one.
+ private bool IsNewOrder => OrderId == 0;
+
+ ///
+ /// Row wrapper used only by the UI.
+ /// RowKey gives each line a stable identity so removing a row removes the correct one.
+ ///
+ private sealed class SalesOrderLineRow
+ {
+ public Guid RowKey { get; set; } = Guid.NewGuid();
+ public SalesOrderLineDto Line { get; set; } = new();
+ }
+
+ ///
+ /// Local payment-term shape for this page.
+ /// The common API returns description data, so this class captures the fields needed
+ /// to populate the dropdown with the same labels as MVC.
+ ///
+ private sealed class PaymentTermOption
+ {
+ public int Id { get; set; }
+ public string? Description { get; set; }
+ public int PaymentType { get; set; }
+ public int? DueAfterDays { get; set; }
+ public bool IsActive { get; set; }
+ }
+
+ ///
+ /// Runs once when the page is created.
+ /// It loads shared dropdown data first, then decides which page mode to use:
+ /// existing order, quotation-based order, or brand-new blank order.
+ ///
+ protected override async Task OnInitializedAsync()
+ {
+ baseApiUrl = Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+
+ try
+ {
+ await LoadDropdownData();
+
+ if (OrderId > 0)
+ {
+ await LoadSalesOrder(OrderId);
+ isEditMode = false;
+ }
+ else if (QuotationId > 0)
+ {
+ await LoadQuotation(QuotationId);
+ isEditMode = true;
+ }
+ else
+ {
+ InitializeNewSalesOrder();
+ isEditMode = true;
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading sales order: {ex.Message}";
+ }
+ finally
+ {
+ isLoading = false;
+ }
+ }
+
+ ///
+ /// Loads all dropdown/reference data needed by the form.
+ /// This includes customers, payment terms, inventory items, and measurements.
+ ///
+ private async Task LoadDropdownData()
+ {
+ customers = await ReadList($"{baseApiUrl}sales/customers");
+ paymentTerms = await ReadList($"{baseApiUrl}common/paymentterms");
+ items = await ReadList($"{baseApiUrl}inventory/items");
+ measurements = await ReadList($"{baseApiUrl}common/measurements");
+ }
+
+ ///
+ /// Generic helper for GET endpoints that return a JSON list.
+ /// If the request fails, it returns an empty list instead of throwing to the UI.
+ ///
+ private async Task> ReadList(string url)
+ {
+ var response = await Http.GetAsync(url);
+ if (!response.IsSuccessStatusCode)
+ {
+ return new List();
+ }
+
+ return await response.Content.ReadFromJsonAsync>() ?? new List();
+ }
+
+ ///
+ /// Loads an existing sales order from the API by id.
+ /// After reading the DTO, it copies values into the date field and rebuilds
+ /// the UI line-row collection used by the editable table.
+ ///
+ private async Task LoadSalesOrder(int id)
+ {
+ var response = await Http.GetAsync($"{baseApiUrl}sales/salesorder?id={id}");
+ if (!response.IsSuccessStatusCode)
+ {
+ errorMessage = "Unable to load sales order.";
+ return;
+ }
+
+ salesOrder = await response.Content.ReadFromJsonAsync() ?? new SalesOrderDto();
+ salesOrder.SalesOrderLines ??= new List();
+ orderDateValue = salesOrder.OrderDate;
+
+ foreach (var line in salesOrder.SalesOrderLines)
+ {
+ PopulateItemNoFromItemId(line);
+ }
+
+ RebuildLineRowsFromDto();
+ }
+
+ ///
+ /// Loads a quotation and converts it into a new sales order draft.
+ /// Header fields and line items are copied from the quotation into the order model.
+ ///
+ private async Task LoadQuotation(int quotationId)
+ {
+ var response = await Http.GetAsync($"{baseApiUrl}sales/quotation?id={quotationId}");
+ if (!response.IsSuccessStatusCode)
+ {
+ errorMessage = "Unable to load quotation.";
+ InitializeNewSalesOrder();
+ return;
+ }
+
+ var quotation = await response.Content.ReadFromJsonAsync();
+ InitializeNewSalesOrder();
+
+ if (quotation is null)
+ {
+ return;
+ }
+
+ salesOrder.CustomerId = quotation.CustomerId ?? 0;
+ salesOrder.PaymentTermId = quotation.PaymentTermId;
+ salesOrder.ReferenceNo = quotation.ReferenceNo ?? string.Empty;
+ salesOrder.OrderDate = quotation.QuotationDate;
+ salesOrder.QuotationId = quotation.Id;
+ orderDateValue = salesOrder.OrderDate;
+
+ foreach (var line in quotation.SalesQuotationLines)
+ {
+ var orderLine = new SalesOrderLineDto
+ {
+ ItemId = line.ItemId,
+ MeasurementId = line.MeasurementId,
+ Quantity = line.Quantity,
+ Amount = line.Amount,
+ Discount = line.Discount
+ };
+
+ PopulateItemNoFromItemId(orderLine);
+ salesOrder.SalesOrderLines.Add(orderLine);
+ }
+
+ RebuildLineRowsFromDto();
+ }
+
+ ///
+ /// Creates a blank sales order for the new-order page.
+ /// Customer and payment term are explicitly set to 0 so the form starts on the
+ /// "-- Select --" options instead of preselecting the first real values.
+ ///
+ private void InitializeNewSalesOrder()
+ {
+ salesOrder = new SalesOrderDto
+ {
+ CustomerId = 0,
+ PaymentTermId = 0,
+ ReferenceNo = string.Empty,
+ OrderDate = DateTime.Today,
+ StatusId = 1,
+ SalesOrderLines = new List()
+ };
+
+ orderDateValue = salesOrder.OrderDate;
+ RebuildLineRowsFromDto();
+ }
+
+ ///
+ /// Rebuilds the UI row collection from the DTO line collection.
+ /// This is needed because the UI uses stable row keys to keep add/remove behavior correct.
+ ///
+ private void RebuildLineRowsFromDto()
+ {
+ lineRows = salesOrder.SalesOrderLines
+ .Select(line => new SalesOrderLineRow { Line = line })
+ .ToList();
+ }
+
+ ///
+ /// Copies the UI rows back into the DTO before validation or save.
+ /// This keeps the API payload in sync with the currently displayed rows.
+ ///
+ private void SyncDtoFromRows()
+ {
+ salesOrder.SalesOrderLines = lineRows
+ .Select(x => x.Line)
+ .ToList();
+ }
+
+ ///
+ /// Runs after an item dropdown changes.
+ /// It fills related fields on that row, such as item code and measurement.
+ ///
+ private void OnItemChanged(SalesOrderLineDto line)
+ {
+ PopulateLineFromItem(line, items.FirstOrDefault(x => x.Id == line.ItemId));
+ }
+
+ ///
+ /// Adds a brand-new editable line item row to the form.
+ /// New rows start blank, with quantity 1 and amount/discount 0.
+ ///
+ private void AddLineItem()
+ {
+ validationErrors.Clear();
+
+ lineRows.Add(new SalesOrderLineRow
+ {
+ Line = new SalesOrderLineDto
+ {
+ ItemId = 0,
+ MeasurementId = 0,
+ Quantity = 1,
+ Amount = 0,
+ Discount = 0,
+ ItemNo = string.Empty
+ }
+ });
+
+ SyncDtoFromRows();
+ }
+
+ ///
+ /// Removes the row with the matching stable key.
+ /// Using RowKey instead of index avoids the wrong-row-removal issue when Blazor reuses DOM rows.
+ ///
+ private void RemoveLineItem(Guid rowKey)
+ {
+ var row = lineRows.FirstOrDefault(x => x.RowKey == rowKey);
+ if (row is null)
+ {
+ return;
+ }
+
+ lineRows.Remove(row);
+ validationErrors.Clear();
+ SyncDtoFromRows();
+ }
+
+ ///
+ /// Validates the form and, if valid, posts the sales order to the backend.
+ /// It first syncs the UI rows back into the DTO, checks header and line-item rules,
+ /// and then submits using the original sales order API endpoint.
+ ///
+ private async Task SaveSalesOrder()
+ {
+ validationErrors.Clear();
+ SyncDtoFromRows();
+
+ if (salesOrder.CustomerId <= 0)
+ validationErrors.Add("Customer is required.");
+ if ((salesOrder.PaymentTermId ?? 0) <= 0)
+ validationErrors.Add("Payment term is required.");
+ if (orderDateValue == default)
+ validationErrors.Add("Date is required.");
+ if (salesOrder.SalesOrderLines.Count == 0)
+ validationErrors.Add("Enter at least 1 line item.");
+
+ foreach (var line in salesOrder.SalesOrderLines)
+ {
+ if ((line.ItemId ?? 0) <= 0)
+ validationErrors.Add("Each line requires an item.");
+ if ((line.MeasurementId ?? 0) <= 0)
+ validationErrors.Add("Each line requires a unit.");
+ if ((line.Quantity ?? 0) <= 0)
+ validationErrors.Add("Each line requires quantity greater than 0.");
+ if ((line.Amount ?? 0) <= 0)
+ validationErrors.Add("Each line requires amount greater than 0.");
+ }
+
+ if (validationErrors.Count > 0)
+ {
+ return;
+ }
+
+ isSaving = true;
+
+ try
+ {
+ salesOrder.OrderDate = orderDateValue;
+
+ var response = await Http.PostAsJsonAsync($"{baseApiUrl}sales/addsalesorder", salesOrder);
+ if (response.IsSuccessStatusCode)
+ {
+ Navigation.NavigateTo("/Sales/SalesOrders", forceLoad: true);
+ }
+ else
+ {
+ var error = await response.Content.ReadAsStringAsync();
+ errorMessage = $"Error saving sales order: {error}";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error saving sales order: {ex.Message}";
+ }
+ finally
+ {
+ isSaving = false;
+ }
+ }
+
+ ///
+ /// Applies item-based defaults to a line after an item is selected.
+ /// It copies item id/code and default measurement, keeps quantity at least 1,
+ /// and intentionally leaves amount at 0 unless the user enters a value.
+ ///
+ private void PopulateLineFromItem(SalesOrderLineDto line, ItemDto? item)
+ {
+ if (item is null)
+ {
+ return;
+ }
+
+ line.ItemId = item.Id;
+ line.ItemNo = item.Code;
+ line.MeasurementId = item.SellMeasurementId ?? 0;
+
+ if (line.Amount is null)
+ {
+ line.Amount = 0;
+ }
+
+ if ((line.Quantity ?? 0) <= 0)
+ {
+ line.Quantity = 1;
+ }
+
+ if (line.Discount is null)
+ {
+ line.Discount = 0;
+ }
+ }
+
+ ///
+ /// Backfills the item code text for a line that was loaded from the API.
+ /// This is useful when the API returns item ids but the UI also wants to display the item code.
+ ///
+ private void PopulateItemNoFromItemId(SalesOrderLineDto line)
+ {
+ if ((line.ItemId ?? 0) <= 0)
+ {
+ return;
+ }
+
+ var item = items.FirstOrDefault(x => x.Id == line.ItemId);
+ if (item is not null)
+ {
+ line.ItemNo = item.Code;
+ }
+ }
+
+ ///
+ /// Calculates the sum of all line totals before tax.
+ /// It uses the UI line collection so the total updates immediately while editing.
+ ///
+ private decimal CalculateSubtotal()
+ {
+ return lineRows.Sum(x => CalculateLineTotal(x.Line));
+ }
+
+ ///
+ /// Placeholder tax calculation.
+ /// Tax is out of scope for this page right now, so it always returns zero.
+ ///
+ private decimal CalculateTax()
+ {
+ return 0m;
+ }
+
+ ///
+ /// Returns the full order total.
+ /// At the moment this is subtotal plus zero tax.
+ ///
+ private decimal CalculateTotal()
+ {
+ return CalculateSubtotal() + CalculateTax();
+ }
+
+ ///
+ /// Calculates one row's line total.
+ /// The formula is quantity times amount, minus discount treated as a percentage.
+ /// This matches the shared DTO/backend logic.
+ ///
+ private decimal CalculateLineTotal(SalesOrderLineDto line)
+ {
+ var quantity = line.Quantity ?? 0m;
+ var amount = line.Amount ?? 0m;
+ var discountPercent = line.Discount ?? 0m;
+
+ var gross = quantity * amount;
+ var discountAmount = discountPercent > 0 ? gross * (discountPercent / 100m) : 0m;
+
+ return gross - discountAmount;
+ }
+
+ ///
+ /// Maps a numeric status id to a user-facing label.
+ /// This keeps status rendering centralized in one place.
+ ///
+ private static string GetStatusLabel(int? statusId) => statusId switch
+ {
+ 0 => "Draft",
+ 1 => "Open",
+ 2 => "Overdue",
+ 3 => "Closed",
+ 4 => "Void",
+ 5 => "Partially Invoiced",
+ 6 => "Fully Invoiced",
+ _ => "Unknown"
+ };
+
+ ///
+ /// Maps a numeric status id to a CSS class used for the status badge.
+ ///
+ private static string GetStatusClass(int? statusId) => statusId switch
+ {
+ 1 => "status-open",
+ 2 => "status-overdue",
+ 3 => "status-closed",
+ 4 => "status-void",
+ 5 => "status-partial",
+ 6 => "status-full",
+ _ => "status-draft"
+ };
+
+ ///
+ /// Returns the label shown in the measurement dropdown.
+ /// It prefers Description, then Code, and falls back to a generic label.
+ ///
+ private static string GetMeasurementLabel(MeasurementDto measurement)
+ {
+ return measurement.Description ?? measurement.Code ?? $"Unit {measurement.Id}";
+ }
+
+ ///
+ /// Returns the label shown in the item dropdown.
+ /// It prefers the item description, then the code, then a fallback label.
+ ///
+ private static string GetItemDisplay(ItemDto item)
+ {
+ return item.Description ?? item.Code ?? $"Item {item.Id}";
+ }
+
+ ///
+ /// Returns the user to the sales orders list page.
+ /// Used by the Cancel button after backing out of the form.
+ ///
+ private void NavigateBack()
+ {
+ Navigation.NavigateTo("/Sales/SalesOrders", forceLoad: true);
+ }
+}
diff --git a/src/AccountGoWeb/Components/Pages/Sales/SalesOrder.razor.css b/src/AccountGoWeb/Components/Pages/Sales/SalesOrder.razor.css
new file mode 100644
index 000000000..19b760aec
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Sales/SalesOrder.razor.css
@@ -0,0 +1,266 @@
+.sales-order-page {
+ padding: 1.75rem;
+ max-width: 1200px;
+}
+
+
+.page-title-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1rem;
+ margin-bottom: 1.25rem;
+}
+
+.page-title-row h1 {
+ margin: 0;
+ font-size: 2rem;
+ font-weight: 650;
+ color: #fff;
+}
+
+.panel-card {
+ background: transparent;
+ border: 1px solid #c7cbd1;
+ border-radius: 4px;
+ overflow: hidden;
+ margin-bottom: 1.25rem;
+}
+
+.panel-body {
+ padding: 1.25rem 1.25rem 1.4rem;
+}
+
+.header-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(280px, 1fr));
+ gap: 1.25rem 1.5rem;
+ align-items: end;
+}
+
+.field label {
+ display: block;
+ margin-bottom: 0.55rem;
+ font-weight: 700;
+ font-size: 1.05rem;
+ color: #fff;
+}
+
+.amount-field {
+ grid-column: 1 / 2;
+}
+
+.form-control,
+.form-select {
+ min-height: 34px;
+ background: #585451;
+ color: #fff;
+ border: 1px solid #6a6764;
+ border-radius: 3px;
+ box-shadow: none;
+}
+
+.form-control:focus,
+.form-select:focus {
+ background: #585451;
+ color: #fff;
+ border-color: #8ea2b5;
+ box-shadow: none;
+}
+
+.form-control[readonly],
+.form-control:disabled,
+.form-select:disabled {
+ background: #585451;
+ color: #fff;
+ opacity: 1;
+}
+
+.readonly-value {
+ min-height: 34px;
+ display: flex;
+ align-items: center;
+ color: #fff;
+ font-weight: 600;
+}
+
+.line-items-section {
+ margin-top: 1.5rem;
+}
+
+.line-items-section h3 {
+ margin: 0 0 0.9rem;
+ font-size: 1.8rem;
+ font-weight: 600;
+ color: #fff;
+}
+
+.line-items-table {
+ margin-bottom: 0;
+ border-collapse: separate;
+ border-spacing: 0;
+}
+
+.line-items-table thead th {
+ background: #f3f4f6;
+ color: #000;
+ font-weight: 700;
+ border-bottom: 2px solid #d1d5db;
+ padding: 0.7rem 0.45rem;
+ white-space: nowrap;
+}
+
+.line-items-table tbody td {
+ background: #fff;
+ vertical-align: middle;
+ padding: 0.35rem;
+ border-top: 0;
+}
+
+.line-items-table tbody .form-control,
+.line-items-table tbody .form-select {
+ min-height: 30px;
+ padding-top: 0.25rem;
+ padding-bottom: 0.25rem;
+}
+
+.empty-line-row {
+ height: 22px;
+ background: #fff;
+}
+
+.add-line-btn {
+ margin-top: 1rem;
+ color: #fff;
+ min-width: 112px;
+ background: #2fa8d8;
+ border-color: #2fa8d8;
+}
+
+.add-line-btn:hover,
+.add-line-btn:focus {
+ color: #fff;
+ background: #2497c8;
+ border-color: #2497c8;
+ box-shadow: none;
+}
+
+.remove-btn {
+ background: #ff6b6b;
+ border-color: #ff6b6b;
+ color: #fff;
+ min-width: 62px;
+}
+
+.remove-btn:hover,
+.remove-btn:focus {
+ background: #ef5b5b;
+ border-color: #ef5b5b;
+ color: #fff;
+ box-shadow: none;
+}
+
+.action-row {
+ display: flex;
+ gap: 0.75rem;
+ margin-top: 1.25rem;
+}
+
+.action-row .btn-success {
+ background: #47c16d;
+ border-color: #47c16d;
+ color: #fff;
+}
+
+.action-row .btn-success:hover,
+.action-row .btn-success:focus {
+ background: #3db261;
+ border-color: #3db261;
+ color: #fff;
+ box-shadow: none;
+}
+
+.action-row .btn-secondary {
+ background: #d5dae0;
+ border-color: #d5dae0;
+ color: #1f2937;
+}
+
+.action-row .btn-secondary:hover,
+.action-row .btn-secondary:focus {
+ background: #c7ced6;
+ border-color: #c7ced6;
+ color: #1f2937;
+ box-shadow: none;
+}
+
+.empty-panel {
+ background: transparent;
+ border: 1px dashed #d4dbe4;
+ border-radius: 6px;
+ padding: 2rem;
+ color: #e5e7eb;
+}
+
+.view-mode-message {
+ margin-bottom: 1rem;
+}
+
+.status-pill {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.2rem 0.65rem;
+ border-radius: 999px;
+ font-size: 0.78rem;
+ font-weight: 700;
+}
+
+.status-draft {
+ background: #eef2f6;
+ color: #52606d;
+}
+
+.status-open {
+ background: #fff3cd;
+ color: #9a6700;
+}
+
+.status-overdue {
+ background: #ffe3e3;
+ color: #c92a2a;
+}
+
+.status-closed {
+ background: #d3f9d8;
+ color: #2b8a3e;
+}
+
+.status-void {
+ background: #eceff4;
+ color: #5c6773;
+}
+
+.status-partial {
+ background: #dbeafe;
+ color: #1d4ed8;
+}
+
+.status-full {
+ background: #dcfce7;
+ color: #15803d;
+}
+
+@media (max-width: 900px) {
+ .header-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .amount-field {
+ grid-column: auto;
+ }
+
+ .page-title-row {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+}
diff --git a/src/AccountGoWeb/Components/Pages/Sales/SalesOrders.razor b/src/AccountGoWeb/Components/Pages/Sales/SalesOrders.razor
new file mode 100644
index 000000000..599a76abe
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Sales/SalesOrders.razor
@@ -0,0 +1,235 @@
+@using SalesOrderDto = Dto.Sales.SalesOrder
+@inject HttpClient Http
+@inject NavigationManager Navigation
+Sales Orders
+
+
+
+
+ @if (isLoading)
+ {
+
Loading sales orders...
+ }
+ else if (!string.IsNullOrWhiteSpace(errorMessage))
+ {
+
@errorMessage
+ }
+ else if (salesOrders.Count == 0)
+ {
+
No sales orders found.
+ }
+ else
+ {
+
+
+
+
+ SortBy(nameof(SalesOrderDto.Id))'>No @GetSortIndicator(nameof(SalesOrderDto.Id))
+ SortBy(nameof(SalesOrderDto.CustomerName))'>Customer Name @GetSortIndicator(nameof(SalesOrderDto.CustomerName))
+ SortBy(nameof(SalesOrderDto.OrderDate))'>Order Date @GetSortIndicator(nameof(SalesOrderDto.OrderDate))
+ SortBy(nameof(SalesOrderDto.ReferenceNo))'>Ref no @GetSortIndicator(nameof(SalesOrderDto.ReferenceNo))
+ Amount
+ SortBy(nameof(SalesOrderDto.StatusId))'>Status @GetSortIndicator(nameof(SalesOrderDto.StatusId))
+
+
+
+ @foreach (var order in GetSortedOrders())
+ {
+ var isSelected = selectedOrder?.Id == order.Id;
+
+ SelectOrder(order)">
+ @order.Id
+ @order.CustomerName
+ @order.OrderDate.ToString("yyyy-MM-dd")
+ @order.ReferenceNo
+ @((order.Amount ?? 0m).ToString("0.00"))
+ @GetStatusLabel(order.StatusId)
+
+ }
+
+
+
+ }
+
+
+@code {
+ // Stores the sales orders shown in the list page.
+ private List salesOrders = new();
+
+ // Tracks the currently selected row.
+ // The View action reads from this value.
+ private SalesOrderDto? selectedOrder;
+
+ // Holds a message when loading fails.
+ private string? errorMessage;
+
+ // True while the page is waiting for the API response.
+ private bool isLoading = true;
+
+ // Name of the property currently used for sorting.
+ private string sortBy = nameof(SalesOrderDto.Id);
+
+ // True means ascending sort, false means descending sort.
+ private bool sortAscending = true;
+
+ // Base API URL loaded from environment so the page can call the backend.
+ private string baseApiUrl = string.Empty;
+
+ ///
+ /// Runs once when the page is first created.
+ /// It reads the API base URL and then loads the sales orders from the server.
+ ///
+ protected override async Task OnInitializedAsync()
+ {
+ baseApiUrl = Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ await LoadSalesOrders();
+ }
+
+ ///
+ /// Calls the sales orders API and fills the table data.
+ /// If the request fails, it stores an error message for the UI.
+ /// It always turns off the loading state when finished.
+ ///
+ private async Task LoadSalesOrders()
+ {
+ try
+ {
+ var response = await Http.GetAsync($"{baseApiUrl}sales/salesorders");
+ if (response.IsSuccessStatusCode)
+ {
+ salesOrders = await response.Content.ReadFromJsonAsync>() ?? new();
+ }
+ else
+ {
+ errorMessage = "Unable to load sales orders.";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading sales orders: {ex.Message}";
+ }
+ finally
+ {
+ isLoading = false;
+ }
+ }
+
+ ///
+ /// Returns the sales order list in the current sort order.
+ /// The method switches on the selected column name and applies either
+ /// ascending or descending ordering to that field.
+ ///
+ private IEnumerable GetSortedOrders() => sortBy switch
+ {
+ nameof(SalesOrderDto.CustomerName) => sortAscending ? salesOrders.OrderBy(x => x.CustomerName) : salesOrders.OrderByDescending(x => x.CustomerName),
+ nameof(SalesOrderDto.OrderDate) => sortAscending ? salesOrders.OrderBy(x => x.OrderDate) : salesOrders.OrderByDescending(x => x.OrderDate),
+ nameof(SalesOrderDto.ReferenceNo) => sortAscending ? salesOrders.OrderBy(x => x.ReferenceNo) : salesOrders.OrderByDescending(x => x.ReferenceNo),
+ nameof(SalesOrderDto.StatusId) => sortAscending ? salesOrders.OrderBy(x => x.StatusId) : salesOrders.OrderByDescending(x => x.StatusId),
+ _ => sortAscending ? salesOrders.OrderBy(x => x.Id) : salesOrders.OrderByDescending(x => x.Id)
+ };
+
+ ///
+ /// Updates the current sort settings when a column header is clicked.
+ /// Clicking the same column flips the sort direction.
+ /// Clicking a different column changes the sort field and resets to ascending.
+ ///
+ private void SortBy(string column)
+ {
+ if (sortBy == column)
+ {
+ sortAscending = !sortAscending;
+ }
+ else
+ {
+ sortBy = column;
+ sortAscending = true;
+ }
+ }
+
+ ///
+ /// Returns the small arrow shown beside the active sorted column.
+ /// Empty string means that column is not currently sorted.
+ ///
+ private string GetSortIndicator(string column)
+ {
+ if (sortBy != column) return string.Empty;
+ return sortAscending ? "↓" : "↑";
+ }
+
+ ///
+ /// Marks a row as selected.
+ /// If the same row is clicked again, it clears the selection.
+ /// The selected row is used to enable the View action.
+ ///
+ private void SelectOrder(SalesOrderDto order)
+ {
+ if (selectedOrder?.Id == order.Id)
+ {
+ selectedOrder = null;
+ return;
+ }
+
+ selectedOrder = order;
+ }
+
+ ///
+ /// Navigates to the read-only sales order detail page for the selected row.
+ /// If nothing is selected, the method exits without doing anything.
+ ///
+ private void ViewSelectedOrder()
+ {
+ if (selectedOrder is null)
+ return;
+
+ Navigation.NavigateTo($"/Sales/SalesOrder?id={selectedOrder.Id}", forceLoad: true);
+ }
+
+ ///
+ /// Opens the page for creating a brand-new sales order.
+ ///
+ private void NavigateToNewOrder()
+ {
+ Navigation.NavigateTo("/Sales/AddSalesOrder", forceLoad: true);
+ }
+
+ ///
+ /// Converts numeric backend status ids into labels shown in the table.
+ /// This keeps the UI readable without exposing raw integer status codes.
+ ///
+ private static string GetStatusLabel(int? statusId) => statusId switch
+ {
+ 0 => "Draft",
+ 1 => "Open",
+ 2 => "Overdue",
+ 3 => "Closed",
+ 4 => "Void",
+ 5 => "Partially Invoiced",
+ 6 => "Fully Invoiced",
+ _ => "N/A"
+ };
+}
+
diff --git a/src/AccountGoWeb/Components/Pages/Sales/SalesOrders.razor.css b/src/AccountGoWeb/Components/Pages/Sales/SalesOrders.razor.css
new file mode 100644
index 000000000..e47e76803
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Sales/SalesOrders.razor.css
@@ -0,0 +1,130 @@
+.sales-orders-page {
+ padding: 1.5rem;
+}
+
+.page-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+}
+
+.page-header h1 {
+ margin: 0;
+ font-size: 2.2rem;
+ font-weight: 700;
+ color: #fff;
+}
+
+.toolbar-actions {
+ display: flex;
+ align-items: center;
+ gap: 1.25rem;
+}
+
+.new-order-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ background: transparent;
+ border: 0;
+ color: #fff;
+ padding: 0.375rem 0.75rem;
+ font-size: 1rem;
+ font-weight: 700;
+ letter-spacing: 0;
+ text-transform: none;
+ box-shadow: none;
+}
+
+.new-order-btn:hover,
+.new-order-btn:focus {
+ background: transparent;
+ color: #20a8d8;
+ text-decoration: none;
+ box-shadow: none;
+}
+
+.toolbar-link {
+ border: 0;
+ background: transparent;
+ color: #c9ced6;
+ font-size: 1rem;
+ font-weight: 700;
+ padding: 0;
+ cursor: pointer;
+ transition: color 0.15s ease, opacity 0.15s ease;
+}
+
+.toolbar-link span {
+ margin-right: 0.25rem;
+}
+
+.toolbar-link:hover:not(:disabled) {
+ color: #fff;
+}
+
+.toolbar-link:disabled {
+ color: #5f6873;
+ opacity: 1;
+ cursor: default;
+}
+
+.toolbar-link:not(:disabled) {
+ color: #fff;
+ font-weight: 800;
+}
+
+.table-shell {
+ overflow: hidden;
+}
+
+.orders-table {
+ margin-bottom: 0;
+ border-collapse: collapse;
+}
+
+.orders-table thead th {
+ background: #23282f;
+ color: #fff;
+ font-weight: 700;
+ border: 0;
+ padding: 0.9rem 0.75rem;
+ white-space: nowrap;
+ cursor: pointer;
+}
+
+.orders-table tbody td {
+ background: #fff;
+ color: #000;
+ padding: 0.85rem 0.75rem;
+ border-top: 1px solid #d7dde5;
+ vertical-align: middle;
+}
+
+.clickable-row {
+ cursor: pointer;
+}
+
+.orders-table tbody tr.clickable-row:hover td {
+ background: #f3f7fb;
+}
+
+.orders-table tbody tr.selected-row td {
+ background: #a9cfdf !important;
+ color: #000;
+ font-weight: 700;
+}
+
+.orders-table tbody tr.selected-row:hover td {
+ background: #a9cfdf !important;
+}
+
+.empty-state {
+ background: #fff;
+ border: 1px dashed #d4dbe4;
+ border-radius: 6px;
+ padding: 2rem;
+ color: #64748b;
+}
diff --git a/src/AccountGoWeb/Components/Pages/Sales/SalesReceipt.razor b/src/AccountGoWeb/Components/Pages/Sales/SalesReceipt.razor
new file mode 100644
index 000000000..a131e8cec
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Sales/SalesReceipt.razor
@@ -0,0 +1,459 @@
+@using System.Text.Json
+@using System.Text.Json.Serialization
+@using AccountGoWeb.Models
+@using AccountGoWeb.Models.Sales
+@using Microsoft.AspNetCore.Components
+
+
+
+
+
+
+ @if (loading)
+ {
+
Loading receipt...
+ }
+ else if (!string.IsNullOrEmpty(errorMessage))
+ {
+
+ Error: @errorMessage
+
+ }
+ else
+ {
+
+ }
+
+
+@code {
+ private bool loading = true;
+ private bool isEditing = false;
+ private string errorMessage = "";
+ private int receiptId = 0;
+ private string receiptNo = "";
+ private int customerId = 0;
+ private int originalCustomerId = 0;
+ private DateTime receiptDate = DateTime.Now;
+ private DateTime originalReceiptDate = DateTime.Now;
+ private int accountToDebitId = 0;
+ private int originalAccountToDebitId = 0;
+ private int accountToCreditId = 0;
+ private int originalAccountToCreditId = 0;
+ private decimal amount = 0;
+ private decimal originalAmount = 0;
+
+ private List<(string Text, string Value)> customers = new();
+ private List<(string Text, string Value)> debitAccounts = new();
+ private List<(string Text, string Value)> creditAccounts = new();
+ private Dictionary customerAdvanceAccounts = new();
+ private Dictionary accountNames = new();
+
+ [SupplyParameterFromQuery(Name = "id")]
+ public int Id { get; set; }
+
+ [Inject]
+ private IHttpClientFactory ClientFactory { get; set; } = null!;
+
+ [Inject]
+ private NavigationManager Navigation { get; set; } = null!;
+
+ protected override async Task OnInitializedAsync()
+ {
+ try
+ {
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+ client.Timeout = TimeSpan.FromSeconds(60);
+
+ // Load customers from API
+ var customersResponse = await client.GetAsync($"{apiurl}sales/customers");
+ if (customersResponse.IsSuccessStatusCode)
+ {
+ var json = await customersResponse.Content.ReadAsStringAsync();
+ var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
+ var customersData = JsonSerializer.Deserialize>(json, options);
+ if (customersData != null)
+ {
+ foreach (var customer in customersData)
+ {
+ var id = GetJsonPropertyString(customer, "id");
+ var name = GetJsonPropertyString(customer, "name");
+ var prepaymentAccountId = GetJsonPropertyString(customer, "prepaymentAccountId");
+ customers.Add((name ?? "Unknown", id));
+
+ // Store the prepayment account ID for this customer
+ if (int.TryParse(id, out var customerId_int) && int.TryParse(prepaymentAccountId, out var accountId_int))
+ {
+ customerAdvanceAccounts[customerId_int] = accountId_int;
+ }
+ }
+ }
+ }
+
+ // Load debit accounts from API (Cash & Banks)
+ var debitResponse = await client.GetAsync($"{apiurl}financials/CashBanks");
+ if (debitResponse.IsSuccessStatusCode)
+ {
+ var json = await debitResponse.Content.ReadAsStringAsync();
+ var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
+ var accountsData = JsonSerializer.Deserialize>(json, options);
+ if (accountsData != null)
+ {
+ foreach (var account in accountsData)
+ {
+ var id = GetJsonPropertyString(account, "id");
+ var name = GetJsonPropertyString(account, "name");
+ debitAccounts.Add((name ?? "Unknown", id));
+ }
+ }
+ }
+
+ // Load credit accounts from API (posting accounts only)
+ var creditResponse = await client.GetAsync($"{apiurl}common/postingaccounts");
+ if (creditResponse.IsSuccessStatusCode)
+ {
+ var json = await creditResponse.Content.ReadAsStringAsync();
+ var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
+ var accountsData = JsonSerializer.Deserialize>(json, options);
+ if (accountsData != null)
+ {
+ foreach (var account in accountsData)
+ {
+ var id = GetJsonPropertyString(account, "id");
+ var name = GetJsonPropertyString(account, "accountName");
+ if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(name))
+ {
+ creditAccounts.Add((name, id));
+ // Store account name for display
+ if (int.TryParse(id, out var accountId))
+ {
+ accountNames[accountId] = name;
+ }
+ }
+ }
+ }
+ }
+
+ // Load all accounts to get customer advance account names
+ var allAccountsResponse = await client.GetAsync($"{apiurl}financials/accounts");
+ if (allAccountsResponse.IsSuccessStatusCode)
+ {
+ var json = await allAccountsResponse.Content.ReadAsStringAsync();
+ var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
+ var accountsData = JsonSerializer.Deserialize>(json, options);
+ if (accountsData != null)
+ {
+ foreach (var account in accountsData)
+ {
+ var id = GetJsonPropertyString(account, "id");
+ var name = GetJsonPropertyString(account, "name");
+ if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(name))
+ {
+ // Store account name for display
+ if (int.TryParse(id, out var accountId))
+ {
+ accountNames[accountId] = name;
+ }
+ }
+ }
+ }
+ }
+
+ // Load receipt data if editing
+ if (Id > 0)
+ {
+ await LoadPayment();
+ }
+
+ loading = false;
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Failed to load form data: {ex.Message}";
+ loading = false;
+ }
+ }
+
+ private async Task LoadPayment()
+ {
+ try
+ {
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+ var client = ClientFactory.CreateClient();
+ var response = await client.GetAsync($"{apiurl}sales/salesreceipt?id={Id}");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var json = await response.Content.ReadAsStringAsync();
+ var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
+ var receiptData = JsonSerializer.Deserialize(json, options);
+
+ if (receiptData.ValueKind == JsonValueKind.Object)
+ {
+ receiptId = GetJsonInt(receiptData, "id") ?? 0;
+ receiptNo = GetJsonPropertyString(receiptData, "receiptNo");
+ customerId = GetJsonInt(receiptData, "customerId") ?? 0;
+ accountToDebitId = GetJsonInt(receiptData, "accountToDebitId") ?? 0;
+ accountToCreditId = GetJsonInt(receiptData, "accountToCreditId") ?? 0;
+ amount = GetJsonDecimal(receiptData, "amount") ?? 0;
+
+ // Store original values
+ originalCustomerId = customerId;
+ originalReceiptDate = receiptDate;
+ originalAccountToDebitId = accountToDebitId;
+ originalAccountToCreditId = accountToCreditId;
+ originalAmount = amount;
+
+ // If credit account is empty but customer is set, auto-populate it
+ if (accountToCreditId == 0 && customerId > 0 && customerAdvanceAccounts.TryGetValue(customerId, out var
+ advanceAccountId))
+ {
+ accountToCreditId = advanceAccountId;
+ }
+
+ if (receiptData.TryGetProperty("receiptDate", out var dateElem))
+ {
+ if (DateTime.TryParse(dateElem.GetString(), out var date))
+ receiptDate = date;
+ }
+ }
+ }
+ else
+ {
+ errorMessage = $"Failed to load receipt. Status: {response.StatusCode}";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading receipt: {ex.Message}";
+ }
+ }
+
+ private int? GetJsonInt(JsonElement elem, string propName)
+ {
+ if (elem.TryGetProperty(propName, out var prop))
+ {
+ if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var val))
+ return val;
+ }
+ return null;
+ }
+
+ private decimal? GetJsonDecimal(JsonElement elem, string propName)
+ {
+ if (elem.TryGetProperty(propName, out var prop))
+ {
+ if (prop.ValueKind == JsonValueKind.Number && prop.TryGetDecimal(out var val))
+ return val;
+ }
+ return null;
+ }
+
+ private string GetJsonPropertyString(JsonElement element, string propertyName)
+ {
+ if (element.TryGetProperty(propertyName, out var property))
+ {
+ if (property.ValueKind == JsonValueKind.String)
+ {
+ return property.GetString() ?? "";
+ }
+ else if (property.ValueKind == JsonValueKind.Number)
+ {
+ return property.GetInt32().ToString();
+ }
+ }
+ return "";
+ }
+
+ private void EnableEdit()
+ {
+ // Store original values
+ originalCustomerId = customerId;
+ originalReceiptDate = receiptDate;
+ originalAccountToDebitId = accountToDebitId;
+ originalAccountToCreditId = accountToCreditId;
+ originalAmount = amount;
+ isEditing = true;
+ }
+
+ private void CancelEdit()
+ {
+ // Restore original values
+ customerId = originalCustomerId;
+ receiptDate = originalReceiptDate;
+ accountToDebitId = originalAccountToDebitId;
+ accountToCreditId = originalAccountToCreditId;
+ amount = originalAmount;
+ isEditing = false;
+ errorMessage = "";
+ }
+
+ private async Task HandleSubmit()
+ {
+ try
+ {
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+
+ var payload = new
+ {
+ Id = receiptId,
+ CustomerId = customerId,
+ ReceiptDate = receiptDate,
+ Amount = amount,
+ AccountToDebitId = accountToDebitId,
+ AccountToCreditId = accountToCreditId
+ };
+
+ var client = ClientFactory.CreateClient();
+ var json = JsonSerializer.Serialize(payload);
+ var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
+
+ // Use UpdateReceipt endpoint when editing
+ var response = await client.PostAsync($"{apiurl}sales/updatereceipt", content);
+
+ if (response.IsSuccessStatusCode)
+ {
+ isEditing = false;
+ // Reload the receipt data
+ await LoadPayment();
+ }
+ else
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ errorMessage = $"Failed to save receipt. Status: {response.StatusCode}";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error saving receipt: {ex.Message}";
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/AccountGoWeb/Components/Pages/Sales/SalesReceipts.razor b/src/AccountGoWeb/Components/Pages/Sales/SalesReceipts.razor
new file mode 100644
index 000000000..0c01ff684
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Sales/SalesReceipts.razor
@@ -0,0 +1,330 @@
+@using Dto.Sales
+@using System.Text.Json
+@using System.Text.Json.Serialization
+
+
+
+
+
+
+ @* SALES RECEIPTS LIST *@
+
+ @if (loading)
+ {
+
+ }
+ else if (errorMessage != null)
+ {
+
+
+ Error: @errorMessage
+
+ }
+ else
+ {
+
+
+
+
+ Payment ID
+ Payment No
+ Customer Name
+ Payment Date
+ Amount
+ Left to Allocate
+
+
+
+ @if (salesReceipts.Count > 0)
+ {
+ @for (int i = 0; i < salesReceipts.Count; i++)
+ {
+ int index = i;
+ OnRowSelected(index))"
+ @ondblclick="@(() => NavigateToSalesReceipt(index))">
+ @GetValue(salesReceipts[index], "id")
+ @GetValue(salesReceipts[index], "receiptNo")
+ @GetValue(salesReceipts[index], "customerName")
+ @FormatDate(GetValue(salesReceipts[index], "receiptDate"))
+ @FormatAmount(GetValue(salesReceipts[index], "amount"))
+ @FormatAmount(GetValue(salesReceipts[index], "remainingAmountToAllocate"))
+
+ }
+ }
+
+
+
+ }
+
+
+@code {
+ [Inject]
+ private IHttpClientFactory ClientFactory { get; set; } = null!;
+
+ [Inject]
+ private NavigationManager Navigation { get; set; } = null!;
+
+ private List salesReceipts = new();
+ private bool loading = true;
+ private string? errorMessage = null;
+ private bool shouldRefresh = false;
+ private string? selectedReceiptId = null;
+ private int selectedRowIndex = -1;
+ private bool isAllocateDisabled = false;
+
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadSalesReceipts();
+ Navigation.LocationChanged += OnLocationChanged;
+ }
+
+ private void OnLocationChanged(object? sender, Microsoft.AspNetCore.Components.Routing.LocationChangedEventArgs e)
+ {
+ // Refresh data when navigating back to this page
+ if (e.Location.Contains("/sales/salesreceipts") || e.Location.Contains("/sales/SalesReceipts"))
+ {
+ shouldRefresh = true;
+ InvokeAsync(StateHasChanged);
+ }
+ }
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (shouldRefresh && !firstRender)
+ {
+ shouldRefresh = false;
+ await LoadSalesReceipts();
+ }
+ }
+
+ public void Dispose()
+ {
+ Navigation.LocationChanged -= OnLocationChanged;
+ }
+
+ private async Task LoadSalesReceipts()
+ {
+ try
+ {
+ loading = true;
+ string apiurl = System.Environment.GetEnvironmentVariable("APIURL") ?? "http://localhost:8001/api/";
+
+ var client = ClientFactory.CreateClient();
+ client.Timeout = TimeSpan.FromSeconds(60);
+
+ var response = await client.GetAsync($"{apiurl}sales/salesreceipts");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var responseString = await response.Content.ReadAsStringAsync();
+ var options = new System.Text.Json.JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true,
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ var data = System.Text.Json.JsonSerializer.Deserialize>(responseString, options);
+ salesReceipts = data?.Cast().ToList() ?? new();
+ }
+ else
+ {
+ errorMessage = $"Failed to load customer payments. Status: {response.StatusCode}";
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Error loading customer payments: {ex.Message}";
+ }
+ finally
+ {
+ loading = false;
+ }
+ }
+
+ private string GetValue(dynamic obj, string propertyName)
+ {
+ try
+ {
+ if (obj is System.Text.Json.JsonElement je)
+ {
+ if (je.TryGetProperty(propertyName, out var prop))
+ {
+ if ((propertyName == "amount" || propertyName == "remainingAmountToAllocate") && prop.ValueKind ==
+ System.Text.Json.JsonValueKind.Number)
+ {
+ return prop.GetDecimal().ToString("F2");
+ }
+ // Handle numeric ID
+ if (propertyName == "id" && prop.ValueKind == System.Text.Json.JsonValueKind.Number)
+ {
+ return prop.GetInt32().ToString();
+ }
+ return prop.GetString() ?? prop.ToString();
+ }
+ }
+ return "N/A";
+ }
+ catch
+ {
+ return "N/A";
+ }
+ }
+
+ private string FormatAmount(string value)
+ {
+ if (decimal.TryParse(value, out var amount))
+ {
+ return amount.ToString("F2");
+ }
+ return value;
+ }
+
+ private string FormatDate(string value)
+ {
+ if (DateTime.TryParse(value, out var date))
+ {
+ return date.ToString("yyyy-MM-dd");
+ }
+ return value;
+ }
+
+ private void OnRowSelected(int index)
+ {
+ // Toggle selection: deselect if clicking the same row
+ if (selectedRowIndex == index)
+ {
+ selectedRowIndex = -1;
+ selectedReceiptId = null;
+ isAllocateDisabled = false;
+ return;
+ }
+
+ selectedRowIndex = index;
+
+ try
+ {
+ if (index >= 0 && index < salesReceipts.Count)
+ {
+ var receipt = salesReceipts[index];
+ if (receipt is System.Text.Json.JsonElement je)
+ {
+ string? receiptId = null;
+
+ if (je.TryGetProperty("id", out var idProp))
+ {
+ if (idProp.ValueKind == System.Text.Json.JsonValueKind.String)
+ {
+ receiptId = idProp.GetString();
+ }
+ else if (idProp.ValueKind == System.Text.Json.JsonValueKind.Number)
+ {
+ receiptId = idProp.GetInt32().ToString();
+ }
+ }
+ else if (je.TryGetProperty("Id", out var idProp2))
+ {
+ if (idProp2.ValueKind == System.Text.Json.JsonValueKind.String)
+ {
+ receiptId = idProp2.GetString();
+ }
+ else if (idProp2.ValueKind == System.Text.Json.JsonValueKind.Number)
+ {
+ receiptId = idProp2.GetInt32().ToString();
+ }
+ }
+
+ if (!string.IsNullOrEmpty(receiptId))
+ {
+ selectedReceiptId = receiptId;
+
+ // Check if there's remaining amount to allocate
+ isAllocateDisabled = true;
+ if (je.TryGetProperty("remainingAmountToAllocate", out var amountProp))
+ {
+ try
+ {
+ if (amountProp.ValueKind == System.Text.Json.JsonValueKind.Number)
+ {
+ var remaining = amountProp.GetDecimal();
+ isAllocateDisabled = remaining <= 0;
+ }
+ }
+ catch
+ {
+ // Amount might not be a number, skip
+ }
+ }
+
+ StateHasChanged();
+ }
+ }
+ }
+ }
+ catch
+ {
+ selectedReceiptId = null;
+ }
+ }
+
+ private void NavigateToSalesReceipt(int index)
+ {
+ if (index >= 0 && index < salesReceipts.Count)
+ {
+ OnRowSelected(index);
+
+ if (selectedReceiptId != null)
+ {
+ Navigation.NavigateTo($"/Sales/SalesReceipt?id={selectedReceiptId}", forceLoad: true);
+ }
+ }
+ }
+
+ private string GetViewReceiptLink()
+ {
+ return selectedReceiptId != null ? $"/Sales/SalesReceipt?id={selectedReceiptId}" : "javascript:void(0)";
+ }
+
+ private string GetAllocateLink()
+ {
+ return selectedReceiptId != null ? $"/Sales/Allocate/{selectedReceiptId}" : "javascript:void(0)";
+ }
+}
\ No newline at end of file
diff --git a/src/AccountGoWeb/Components/Pages/Students.razor b/src/AccountGoWeb/Components/Pages/Students.razor
new file mode 100644
index 000000000..443d791b0
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Students.razor
@@ -0,0 +1,23 @@
+@page "/students"
+@rendermode InteractiveServer
+Students
+Students
+
+
+
+
+
+ @context.FirstName @context.LastName
+
+
+
+
+
+
+
+@code {
+ IQueryable students = Student.GetStudents();
+ PaginationState pagination = new PaginationState { ItemsPerPage = 10 };
+ GridSort sortByName = GridSort
+ .ByAscending(_ => _.FirstName).ThenAscending(_ => _.LastName);
+}
\ No newline at end of file
diff --git a/src/AccountGoWeb/Components/Pages/Tax/TaxManagement.razor b/src/AccountGoWeb/Components/Pages/Tax/TaxManagement.razor
new file mode 100644
index 000000000..ff9d626e1
--- /dev/null
+++ b/src/AccountGoWeb/Components/Pages/Tax/TaxManagement.razor
@@ -0,0 +1,427 @@
+@rendermode InteractiveServer
+@inject HttpClient Http
+@inject NavigationManager Navigation
+@inject IConfiguration Configuration
+@inject IJSRuntime JSRuntime
+
+Tax Management
+
+
+
+
+
+ @if (activeTab == "taxes")
+ {
+
+
+
+
+
+
+ Code
+ Name
+ Rate (%)
+ Action
+
+
+
+ @if (taxes != null && taxes.Any())
+ {
+ @foreach (var tax in taxes)
+ {
+ EditTax(tax)" style="cursor: pointer;">
+ @tax.TaxCode
+ @tax.TaxName
+ @tax.Rate
+
+ ConfirmDeleteTax(tax.Id)">Delete
+
+
+ }
+ }
+ else if (isLoading)
+ {
+
+ Loading...
+
+ }
+ else
+ {
+
+ No taxes found.
+
+ }
+
+
+
+
+ }
+ else if (activeTab == "tax-groups")
+ {
+
+
+
+ @if (selectedTaxGroup != null)
+ {
+
+
Tax(es) included: @GetTaxGroupTaxCount(selectedTaxGroup)
+
+
+
+
+ Code
+ Name
+ Rate (%)
+
+
+
+ @foreach (var groupTax in GetTaxGroupTaxes(selectedTaxGroup))
+ {
+
+ @groupTax.TaxCode
+ @groupTax.TaxName
+ @groupTax.Rate
+
+ }
+
+
+
+
+ }
+
+ }
+ else if (activeTab == "item-tax-groups")
+ {
+
+
+
+ @if (selectedItemTaxGroup != null)
+ {
+
+
Tax(es) included: @GetItemTaxGroupTaxCount(selectedItemTaxGroup)
+
+
+
+
+ Code
+ Name
+ Rate (%)
+
+
+
+ @foreach (var groupTax in GetItemTaxGroupTaxes(selectedItemTaxGroup))
+ {
+
+ @groupTax.TaxCode
+ @groupTax.TaxName
+ @groupTax.Rate
+
+ }
+
+
+
+
+ }
+
+ }
+
+
+
+@* Delete Confirmation Modals would use JavaScript interop or a confirmation component *@
+
+@code {
+ private string activeTab = "taxes";
+ private bool isLoading = true;
+
+ private List? taxes;
+ private List? taxGroups;
+ private List? itemTaxGroups;
+
+ private Dto.TaxSystem.TaxGroup? selectedTaxGroup;
+ private Dto.TaxSystem.ItemTaxGroup? selectedItemTaxGroup;
+
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadTaxData();
+ }
+
+ private async Task LoadTaxData()
+ {
+ isLoading = true;
+ try
+ {
+ var baseUri = Configuration["ApiUrl"];
+ var response = await Http.GetAsync($"{baseUri}tax/taxes");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var content = await response.Content.ReadAsStringAsync();
+ var taxSystemDto = System.Text.Json.JsonSerializer.Deserialize(
+ content,
+ new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }
+ );
+
+ taxes = taxSystemDto?.Taxes?.ToList();
+ taxGroups = taxSystemDto?.TaxGroups?.ToList();
+ itemTaxGroups = taxSystemDto?.ItemTaxGroups?.ToList();
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error loading tax data: {ex.Message}");
+ }
+ finally
+ {
+ isLoading = false;
+ }
+ }
+
+ private void SetActiveTab(string tab)
+ {
+ activeTab = tab;
+ selectedTaxGroup = null;
+ selectedItemTaxGroup = null;
+ }
+
+ private void EditTax(Dto.TaxSystem.Tax tax)
+ {
+ Navigation.NavigateTo($"/Tax/EditTax?id={tax.Id}", forceLoad: true);
+ }
+
+ private async Task ConfirmDeleteTax(int taxId)
+ {
+ bool confirmed = await JSRuntime.InvokeAsync("confirmDialog.show", "Are you sure you want to delete this tax?");
+ if (confirmed)
+ {
+ try
+ {
+ var baseUri = Configuration["ApiUrl"];
+ var response = await Http.DeleteAsync($"{baseUri}tax/deletetax?id={taxId}");
+
+ if (response.IsSuccessStatusCode)
+ {
+ await LoadTaxData(); // Reload the data
+ }
+ else
+ {
+ Console.WriteLine($"Error deleting tax: {response.StatusCode}");
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error deleting tax: {ex.Message}");
+ }
+ }
+ }
+
+ private void SelectTaxGroup(Dto.TaxSystem.TaxGroup group)
+ {
+ selectedTaxGroup = group;
+ }
+
+ private async Task ConfirmDeleteTaxGroup(int groupId)
+ {
+ bool confirmed = await JSRuntime.InvokeAsync("confirmDialog.show", "Are you sure you want to delete this tax group?");
+ if (confirmed)
+ {
+ try
+ {
+ var baseUri = Configuration["ApiUrl"];
+ var response = await Http.DeleteAsync($"{baseUri}tax/deletetaxgroup?id={groupId}");
+
+ if (response.IsSuccessStatusCode)
+ {
+ await LoadTaxData(); // Reload the data
+ selectedTaxGroup = null;
+ }
+ else
+ {
+ Console.WriteLine($"Error deleting tax group: {response.StatusCode}");
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error deleting tax group: {ex.Message}");
+ }
+ }
+ }
+
+ private void SelectItemTaxGroup(Dto.TaxSystem.ItemTaxGroup group)
+ {
+ selectedItemTaxGroup = group;
+ }
+
+ private async Task ConfirmDeleteItemTaxGroup(int groupId)
+ {
+ bool confirmed = await JSRuntime.InvokeAsync("confirmDialog.show", "Are you sure you want to delete this item tax group?");
+ if (confirmed)
+ {
+ try
+ {
+ var baseUri = Configuration["ApiUrl"];
+ var response = await Http.DeleteAsync($"{baseUri}tax/deleteitemtaxgroup?id={groupId}");
+
+ if (response.IsSuccessStatusCode)
+ {
+ await LoadTaxData(); // Reload the data
+ selectedItemTaxGroup = null;
+ }
+ else
+ {
+ Console.WriteLine($"Error deleting item tax group: {response.StatusCode}");
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error deleting item tax group: {ex.Message}");
+ }
+ }
+ }
+
+ private int GetTaxGroupTaxCount(Dto.TaxSystem.TaxGroup group)
+ {
+ return group.Taxes?.Count() ?? 0;
+ }
+
+ private IEnumerable GetTaxGroupTaxes(Dto.TaxSystem.TaxGroup group)
+ {
+ if (group.Taxes == null || taxes == null) return Enumerable.Empty();
+
+ return group.Taxes
+ .Select(gt => taxes.FirstOrDefault(t => t.Id == gt.TaxId))
+ .Where(t => t != null)
+ .Cast