├── .all-contributorsrc ├── .editorconfig ├── .gitattributes ├── .github ├── FUNDING.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── api-deploy.yml │ ├── client-deploy.yml │ ├── codeql-analysis.yml │ ├── continuous-deploy.yml │ ├── policy-dotnetcore.yml │ └── policy-npm.yml ├── .gitignore ├── .markdownlint.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── api ├── .vscode │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── Directory.Build.props ├── Directory.Packages.props ├── PayrollProcessor.Core.Domain.Tests │ ├── Features │ │ └── Employees │ │ │ ├── EmployeeCreateCommandTests.cs │ │ │ └── EmployeePayrollCreateCommandTests.cs │ └── PayrollProcessor.Core.Domain.Tests.csproj ├── PayrollProcessor.Core.Domain │ ├── Features │ │ ├── Departments │ │ │ ├── DepartmentEmployee.cs │ │ │ ├── DepartmentEmployeeCreateCommand.cs │ │ │ ├── DepartmentEmployeeQuery.cs │ │ │ ├── DepartmentEmployeeUpdateCommand.cs │ │ │ ├── DepartmentEmployeesQuery.cs │ │ │ ├── DepartmentPayroll.cs │ │ │ ├── DepartmentPayrollCreateCommand.cs │ │ │ ├── DepartmentPayrollQuery.cs │ │ │ ├── DepartmentPayrollUpdateCommand.cs │ │ │ └── DepartmentPayrollsQuery.cs │ │ ├── Employees │ │ │ ├── Employee.cs │ │ │ ├── EmployeeCreateCommand.cs │ │ │ ├── EmployeeDepartment.cs │ │ │ ├── EmployeeDetailQuery.cs │ │ │ ├── EmployeeNew.cs │ │ │ ├── EmployeePayroll.cs │ │ │ ├── EmployeePayrollCreateCommand.cs │ │ │ ├── EmployeePayrollNew.cs │ │ │ ├── EmployeePayrollQuery.cs │ │ │ ├── EmployeePayrollUpdateCommand.cs │ │ │ ├── EmployeeQuery.cs │ │ │ ├── EmployeeStatus.cs │ │ │ ├── EmployeeUpdateCommand.cs │ │ │ └── EmployeesQuery.cs │ │ └── Resources │ │ │ └── ResourceCountQuery.cs │ ├── Intrastructure │ │ ├── Clocks │ │ │ └── DateTimeProvider.cs │ │ ├── Identifiers │ │ │ └── EntityIdGenerator.cs │ │ ├── LanguageExtensions │ │ │ ├── TryAsyncLanguageExtensions.cs │ │ │ └── TryOptionAsyncLanguageExtensions.cs │ │ ├── Operations │ │ │ ├── Commands │ │ │ │ ├── CommandDispatcher.cs │ │ │ │ ├── ICommand.cs │ │ │ │ ├── ICommandDispatcher.cs │ │ │ │ └── ICommandHandler.cs │ │ │ ├── Factories │ │ │ │ ├── HandlerBase.cs │ │ │ │ └── ServiceProviderDelegate.cs │ │ │ └── Queries │ │ │ │ ├── IQuery.cs │ │ │ │ ├── IQueryDispatcher.cs │ │ │ │ ├── IQueryHandler.cs │ │ │ │ └── QueryDispatcher.cs │ │ └── Serialization │ │ │ └── DefaultJsonSerializerSettings.cs │ └── PayrollProcessor.Core.Domain.csproj ├── PayrollProcessor.Data.Persistence.Tests │ ├── Features │ │ └── Employees │ │ │ └── EmployeeRecordMapTests.cs │ └── PayrollProcessor.Data.Persistence.Tests.csproj ├── PayrollProcessor.Data.Persistence │ ├── Features │ │ ├── Departments │ │ │ ├── DepartmentEmployeeCreateCommandHandler.cs │ │ │ ├── DepartmentEmployeeQueryHandler.cs │ │ │ ├── DepartmentEmployeeRecord.cs │ │ │ ├── DepartmentEmployeeUpdateCommandHandler.cs │ │ │ ├── DepartmentEmployeesQueryHandler.cs │ │ │ ├── DepartmentPayrollCreateCommandHandler.cs │ │ │ ├── DepartmentPayrollQueryHandler.cs │ │ │ ├── DepartmentPayrollRecord.cs │ │ │ ├── DepartmentPayrollUpdateCommandHandler.cs │ │ │ └── DepartmentPayrollsQueryHandler.cs │ │ ├── Employees │ │ │ ├── EmployeeCreateCommandHandler.cs │ │ │ ├── EmployeeDetailQueryHandler.cs │ │ │ ├── EmployeePayrollCreateCommandHandler.cs │ │ │ ├── EmployeePayrollQueryHandler.cs │ │ │ ├── EmployeePayrollRecord.cs │ │ │ ├── EmployeePayrollUpdateCommandHandler.cs │ │ │ ├── EmployeeQueryHandler.cs │ │ │ ├── EmployeeRecord.cs │ │ │ ├── EmployeeUpdateCommandHandler.cs │ │ │ ├── EmployeesQueryHandler.cs │ │ │ └── QueueMessages │ │ │ │ ├── EmployeeCreation.cs │ │ │ │ ├── EmployeePayrollCreation.cs │ │ │ │ ├── EmployeePayrollUpdate.cs │ │ │ │ └── EmployeeUpdate.cs │ │ └── Resources │ │ │ └── ResourceCountQueryHandler.cs │ ├── Infrastructure │ │ ├── Clients │ │ │ ├── AppResources.cs │ │ │ ├── CosmosClientExtensions.cs │ │ │ └── QueueClientFactory.cs │ │ └── Records │ │ │ └── CosmosDbRecord.cs │ └── PayrollProcessor.Data.Persistence.csproj ├── PayrollProcessor.Functions.Api.Tests │ └── PayrollProcessor.Functions.Api.Tests.csproj ├── PayrollProcessor.Functions.Api │ ├── Features │ │ ├── Departments │ │ │ ├── DepartmentEmployeeTrigger.cs │ │ │ └── DepartmentPayrollTrigger.cs │ │ └── Resources │ │ │ ├── ResourceManager.cs │ │ │ └── ResourcesTrigger.cs │ ├── Infrastructure │ │ ├── ApiClient.cs │ │ ├── EnvironmentSettings.cs │ │ └── QueueMessageHandler.cs │ ├── PayrollProcessor.Functions.Api.csproj │ ├── Startup.cs │ ├── host.json │ └── local.settings.json.sample ├── PayrollProcessor.Infrastructure.Seeding │ ├── Features │ │ └── Generators │ │ │ ├── DomainSeed.cs │ │ │ └── EmployeeSeed.cs │ ├── Infrastructure │ │ └── DomainFaker.cs │ └── PayrollProcessor.Infrastructure.Seeding.csproj ├── PayrollProcessor.Tests │ ├── Fixtures │ │ ├── AutoDomainData.cs │ │ └── DomainFixture.cs │ └── PayrollProcessor.Tests.csproj ├── PayrollProcessor.Web.Api.Tests │ └── PayrollProcessor.Web.Api.Tests.csproj ├── PayrollProcessor.Web.Api │ ├── .dockerignore │ ├── Configuration │ │ └── Persistence │ │ │ └── PersistenceConfigurationExtensions.cs │ ├── Dockerfile │ ├── Features │ │ ├── Departments │ │ │ ├── DepartmentEmployeesGet.cs │ │ │ └── DepartmentPayrollsGet.cs │ │ ├── Employees │ │ │ ├── EmployeeCreate.cs │ │ │ ├── EmployeeGet.cs │ │ │ ├── EmployeePayrollCreate.cs │ │ │ ├── EmployeePayrollUpdate.cs │ │ │ ├── EmployeeUpdate.cs │ │ │ └── EmployeesGet.cs │ │ ├── Notifications │ │ │ ├── Notification.cs │ │ │ ├── NotificationController.cs │ │ │ └── NotificationHub.cs │ │ └── Resources │ │ │ └── ResourceStatsGet.cs │ ├── Infrastructure │ │ ├── Responses │ │ │ ├── APIErrorResult.cs │ │ │ └── IListResponse.cs │ │ └── Routing │ │ │ └── GlobalRouteConvention.cs │ ├── PayrollProcessor.Web.Api.csproj │ ├── Program.cs │ ├── Properties │ │ └── launchSettings.json │ ├── appsettings.Development.json │ └── appsettings.json └── PayrollProcessor.sln ├── bicep ├── api.bicep ├── function.bicep └── main.bicep ├── client ├── .browserslistrc ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode │ └── tasks.json ├── README.md ├── angular.json ├── e2e │ ├── protractor.conf.js │ ├── src │ │ ├── app.e2e-spec.ts │ │ └── app.po.ts │ └── tsconfig.json ├── import-sorter.json ├── karma.conf.js ├── package-lock.json ├── package.json ├── src │ ├── app │ │ ├── admin │ │ │ ├── admin.component.html │ │ │ ├── admin.component.scss │ │ │ ├── admin.component.ts │ │ │ ├── admin.module.ts │ │ │ └── state │ │ │ │ ├── resources.client.ts │ │ │ │ ├── resources.query.ts │ │ │ │ ├── resources.service.ts │ │ │ │ └── resources.store.ts │ │ ├── app-routing.module.ts │ │ ├── app.component.html │ │ ├── app.component.scss │ │ ├── app.component.spec.ts │ │ ├── app.component.ts │ │ ├── app.module.ts │ │ ├── core │ │ │ ├── core.module.ts │ │ │ └── not-found.component.ts │ │ ├── department │ │ │ └── department.model.ts │ │ ├── employee │ │ │ ├── employee-create │ │ │ │ ├── employee-create.component.html │ │ │ │ ├── employee-create.component.scss │ │ │ │ ├── employee-create.component.spec.ts │ │ │ │ └── employee-create.component.ts │ │ │ ├── employee-detail │ │ │ │ ├── employee-detail.component.html │ │ │ │ ├── employee-detail.component.scss │ │ │ │ ├── employee-detail.component.spec.ts │ │ │ │ ├── employee-detail.component.ts │ │ │ │ └── state │ │ │ │ │ ├── employee-detail.model.ts │ │ │ │ │ ├── employee-detail.query.ts │ │ │ │ │ ├── employee-detail.service.ts │ │ │ │ │ └── employee-detail.store.ts │ │ │ ├── employee-list │ │ │ │ ├── employee-list.component.spec.ts │ │ │ │ ├── employee-list.component.ts │ │ │ │ └── state │ │ │ │ │ ├── employee-list.model.ts │ │ │ │ │ ├── employee-list.query.ts │ │ │ │ │ ├── employee-list.service.ts │ │ │ │ │ └── employee-list.store.ts │ │ │ ├── employee-payroll-create │ │ │ │ ├── employee-payroll-create.component.html │ │ │ │ ├── employee-payroll-create.component.scss │ │ │ │ ├── employee-payroll-create.component.spec.ts │ │ │ │ └── employee-payroll-create.component.ts │ │ │ ├── employee-payroll-list │ │ │ │ ├── employee-payroll-list.component.spec.ts │ │ │ │ └── employee-payroll-list.component.ts │ │ │ ├── employee.component.html │ │ │ ├── employee.component.scss │ │ │ ├── employee.component.spec.ts │ │ │ ├── employee.component.ts │ │ │ └── employee.module.ts │ │ ├── payroll │ │ │ ├── payroll-list │ │ │ │ ├── payroll-list.component.html │ │ │ │ ├── payroll-list.component.scss │ │ │ │ ├── payroll-list.component.spec.ts │ │ │ │ ├── payroll-list.component.ts │ │ │ │ └── state │ │ │ │ │ ├── payroll-list.model.ts │ │ │ │ │ ├── payroll-list.query.ts │ │ │ │ │ ├── payroll-list.service.ts │ │ │ │ │ └── payroll-list.store.ts │ │ │ ├── payroll.component.html │ │ │ ├── payroll.component.scss │ │ │ ├── payroll.component.spec.ts │ │ │ ├── payroll.component.ts │ │ │ └── payroll.module.ts │ │ └── shared │ │ │ ├── api-error.ts │ │ │ ├── clock.service.ts │ │ │ ├── env.service.ts │ │ │ ├── list-response.ts │ │ │ ├── notification.service.ts │ │ │ ├── shared.module.ts │ │ │ ├── styles │ │ │ ├── _mixins.scss │ │ │ └── _variables.scss │ │ │ └── unslugify.pipe.ts │ ├── assets │ │ └── .gitkeep │ ├── environments │ │ ├── environment-types.ts │ │ ├── environment.dev.ts │ │ ├── environment.local.sample.ts │ │ ├── environment.prod.ts │ │ └── environment.ts │ ├── favicon.ico │ ├── index.html │ ├── main.ts │ ├── polyfills.ts │ ├── styles.scss │ └── test.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.spec.json └── tslint.json ├── docs ├── ARCHITECTURAL_DECISION_RECORD.md ├── ARCHITECTURE.md ├── PayrollProcessor.postman_collection.json └── Pipelines.drawio ├── payroll-processor.code-workspace └── vue-client ├── .env ├── .eslintrc.cjs ├── .gitignore ├── .vscode └── extensions.json ├── README.md ├── cypress.json ├── cypress ├── fixtures │ └── example.json ├── integration │ └── example.spec.ts ├── plugins │ ├── index.ts │ └── tsconfig.json ├── support │ ├── commands.ts │ └── index.ts └── tsconfig.json ├── env.d.ts ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── favicon.ico ├── settings.ts ├── src ├── App.vue ├── assets │ ├── base.css │ └── logo.svg ├── components │ ├── Admin.vue │ ├── Header.vue │ ├── TheWelcome.vue │ ├── ThemeToggle.vue │ ├── WelcomeItem.vue │ ├── __tests__ │ │ └── Admin.spec.ts │ └── icons │ │ ├── IconCommunity.vue │ │ ├── IconDocumentation.vue │ │ ├── IconEcosystem.vue │ │ ├── IconSupport.vue │ │ └── IconTooling.vue ├── main.ts ├── router │ └── index.ts ├── stores │ └── admin.ts └── views │ ├── AboutView.vue │ ├── AdminView.vue │ └── HomeView.vue ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.vite-config.json ├── tsconfig.vitest.json └── vite.config.ts /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "KyleMcMaster", 10 | "name": "Kyle McMaster", 11 | "avatar_url": "https://avatars1.githubusercontent.com/u/11415127?v=4", 12 | "profile": "https://github.com/KyleMcMaster", 13 | "contributions": [ 14 | "design", 15 | "code", 16 | "test" 17 | ] 18 | }, 19 | { 20 | "login": "seangwright", 21 | "name": "Sean G. Wright", 22 | "avatar_url": "https://avatars3.githubusercontent.com/u/1382768?v=4", 23 | "profile": "https://www.seangwright.me", 24 | "contributions": [ 25 | "design", 26 | "code", 27 | "review" 28 | ] 29 | }, 30 | { 31 | "login": "eyev", 32 | "name": "Justin Conklin", 33 | "avatar_url": "https://avatars2.githubusercontent.com/u/2951907?v=4", 34 | "profile": "https://conklin.dev", 35 | "contributions": [ 36 | "review", 37 | "code" 38 | ] 39 | } 40 | ], 41 | "contributorsPerLine": 7, 42 | "projectName": "payroll-processor", 43 | "projectOwner": "KyleMcMaster", 44 | "repoType": "github", 45 | "repoHost": "https://github.com", 46 | "skipCi": true 47 | } 48 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # These are set for devs that don't have global .gitconfig settings for line endings 2 | # https://stackoverflow.com/questions/10418975/how-to-change-line-ending-settings/40821931#40821931 3 | # https://help.github.com/en/github/using-git/configuring-git-to-handle-line-endings 4 | * text=auto 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [KyleMcMaster] 4 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | [[ Describe this PR ]] 4 | 5 | ## Purpose 6 | 7 | - [ ] Feature 8 | - [ ] Partial Feature 9 | - [ ] Bugfix 10 | - [ ] Docs, Config 11 | - [ ] Chore / Format 12 | 13 | ## Change Type 14 | 15 | - [ ] Major 16 | - [ ] Minor 17 | - [ ] Fix 18 | 19 | ## Data Persistence Changes 20 | 21 | [[ Describe changes ]] 22 | 23 | ## Configuration Changes 24 | 25 | [[ Describe changes ]] 26 | -------------------------------------------------------------------------------- /.github/workflows/api-deploy.yml: -------------------------------------------------------------------------------- 1 | # Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy 2 | # More GitHub Actions for Azure: https://github.com/Azure/actions 3 | 4 | name: Build and deploy ASP.Net Core app to Azure Web App 5 | 6 | on: 7 | workflow_call: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up .NET Core 17 | uses: actions/setup-dotnet@v1 18 | with: 19 | dotnet-version: "6.0.x" 20 | include-prerelease: true 21 | 22 | - name: Build with dotnet 23 | run: dotnet build api/PayrollProcessor.sln --configuration Release 24 | 25 | - name: dotnet publish 26 | run: dotnet publish api/PayrollProcessor.Web.Api/PayrollProcessor.Web.Api.csproj --no-build -c Release -o ${{env.DOTNET_ROOT}}/PayrollProcessor.Web.Api 27 | 28 | - name: Upload artifact for deployment job 29 | uses: actions/upload-artifact@v2 30 | with: 31 | name: .net-app 32 | path: ${{env.DOTNET_ROOT}}/PayrollProcessor.Web.Api 33 | 34 | deploy: 35 | runs-on: ubuntu-latest 36 | needs: build 37 | environment: 38 | name: "Production" 39 | url: ${{ steps.deploy-to-webapp.outputs.webapp-url }} 40 | 41 | steps: 42 | - name: Download artifact from build job 43 | uses: actions/download-artifact@v2 44 | with: 45 | name: .net-app 46 | 47 | - name: Deploy to Azure Web App 48 | id: deploy-to-webapp 49 | uses: azure/webapps-deploy@v2 50 | with: 51 | app-name: ${{ secrets.APIAPPNAME }} 52 | slot-name: "Production" 53 | publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_0F6661018B1141E5B1D9096705F9A959 }} 54 | package: . 55 | -------------------------------------------------------------------------------- /.github/workflows/client-deploy.yml: -------------------------------------------------------------------------------- 1 | # Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy 2 | # More GitHub Actions for Azure: https://github.com/Azure/actions 3 | 4 | name: Build and deploy Node.js app to Azure Web App 5 | 6 | on: 7 | workflow_call: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Node.js version 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: "14.x" 20 | 21 | - name: npm install, build, and test 22 | run: | 23 | cd client 24 | npm install 25 | npm run build --if-present 26 | # npm run test --if-present 27 | 28 | - name: Upload artifact for deployment job 29 | uses: actions/upload-artifact@v2 30 | with: 31 | name: node-app 32 | path: . 33 | 34 | deploy: 35 | runs-on: ubuntu-latest 36 | needs: build 37 | environment: 38 | name: "Production" 39 | url: ${{ steps.deploy-to-webapp.outputs.webapp-url }} 40 | 41 | steps: 42 | - name: Download artifact from build job 43 | uses: actions/download-artifact@v2 44 | with: 45 | name: node-app 46 | 47 | - name: "Deploy to Azure Web App" 48 | uses: azure/webapps-deploy@v2 49 | id: deploy-to-webapp 50 | with: 51 | app-name: ${{ secrets.CLIENTAPPNAME }} 52 | slot-name: "Production" 53 | publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_1815DEAC68AC4EE1801AB0AA15DAF698 }} 54 | package: ./dist/payroll-processor-client 55 | -------------------------------------------------------------------------------- /.github/workflows/continuous-deploy.yml: -------------------------------------------------------------------------------- 1 | on: 2 | # Trigger the workflow on push or pull request, 3 | # but only for the main branch 4 | push: 5 | branches: 6 | - master 7 | name: Continuous Deploy 8 | jobs: 9 | arm-deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | # Checkout code 13 | - uses: actions/checkout@main 14 | 15 | # Log into Azure 16 | - uses: azure/login@v1 17 | with: 18 | creds: ${{ secrets.AZURE_CREDENTIALS }} 19 | 20 | # Deploy ARM Bicep file 21 | # - name: ARM Deploy 22 | # uses: azure/arm-deploy@v1 23 | # with: 24 | # subscriptionId: ${{ secrets.AZURE_SUBSCRIPTION }} 25 | # resourceGroupName: ${{ secrets.AZURE_RG }} 26 | # template: ./bicep/main.bicep 27 | # failOnStdErr: false 28 | 29 | # Deploy .Net Core API 30 | #api-deploy: 31 | # needs: arm-deploy 32 | # uses: "KyleMcMaster/payroll-processor/.github/workflows/api-deploy.yml@master" #todo update branch 33 | 34 | # Deploy Angular Client 35 | #client-deploy: 36 | # needs: arm-deploy 37 | # uses: "KyleMcMaster/payroll-processor/.github/workflows/client-deploy.yml@master" 38 | 39 | # Deploy Azure Functions 40 | # api-deploy: 41 | # needs: arm-deploy 42 | # uses: "KyleMcMaster/payroll-processor/.github/workflows/api-deploy.yml@master" 43 | -------------------------------------------------------------------------------- /.github/workflows/policy-dotnetcore.yml: -------------------------------------------------------------------------------- 1 | # build and test PayrollProcessor.sln 2 | name: dotnet core - build & test 3 | 4 | on: 5 | pull_request: 6 | branches: 7 | - main 8 | push: 9 | branches: [ main ] 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Setup .NET Core 17 | uses: actions/setup-dotnet@v1 18 | with: 19 | dotnet-version: "6.0.x" 20 | - name: Build with dotnet 21 | run: dotnet build api/PayrollProcessor.sln --configuration Release 22 | - name: Unit Tests 23 | run: dotnet test api/PayrollProcessor.sln 24 | -------------------------------------------------------------------------------- /.github/workflows/policy-npm.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build and test a node.js application for PR verification 2 | # 3 | # For more information on GitHub Actions for Azure, refer to https://github.com/Azure/Actions 4 | # For more samples to get started with GitHub Action workflows to deploy to Azure, refer to https://github.com/Azure/actions-workflow-samples 5 | name: client - build & test 6 | 7 | on: 8 | pull_request: 9 | branches: 10 | - main 11 | push: 12 | branches: [ main ] 13 | 14 | env: 15 | NODE_VERSION: "16.x" # set this to the node version to use 16 | 17 | jobs: 18 | build-and-test-angular: 19 | name: Angular - Build and Test 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ env.NODE_VERSION }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ env.NODE_VERSION }} 27 | - name: npm install, build, and test 28 | run: | 29 | # Build and test the project 30 | cd client 31 | npm install 32 | npm run build --if-present 33 | # npm run test --if-present 34 | 35 | build-and-test-vue: 36 | name: Vue - Build and Test 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v2 40 | - name: Use Node.js ${{ env.NODE_VERSION }} 41 | uses: actions/setup-node@v1 42 | with: 43 | node-version: ${{ env.NODE_VERSION }} 44 | - name: npm install, build, and test 45 | run: | 46 | # Build and test the project 47 | cd vue-client 48 | npm install 49 | npm run build --if-present 50 | # npm run test:unit --if-present 51 | # npm run test:e2e:ci --if-present 52 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "MD013": { "line_length": 140 }, 3 | "MD024": { "allow_different_nesting": true } 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Kyle R. McMaster 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /api/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "API: Run & Attach (Debug)", 6 | "type": "coreclr", 7 | "preLaunchTask": "API: Build (Debug)", 8 | "request": "launch", 9 | "program": "${workspaceFolder}/PayrollProcessor.Web.Api/bin/Debug/net6.0/PayrollProcessor.Web.Api.dll", 10 | "args": [], 11 | "cwd": "${workspaceFolder}/PayrollProcessor.Web.Api/bin/Debug/net6.0", 12 | "stopAtEntry": false, 13 | "internalConsoleOptions": "openOnSessionStart", 14 | "env": { 15 | "ASPNETCORE_ENVIRONMENT": "Development", 16 | "ASPNETCORE_URLS": "http://localhost:5000" 17 | }, 18 | "launchBrowser": { 19 | "enabled": false, 20 | "args": "${auto-detect-url}", 21 | "windows": { 22 | "command": "cmd.exe", 23 | "args": "/C start ${auto-detect-url}" 24 | }, 25 | "osx": { 26 | "command": "open" 27 | }, 28 | "linux": { 29 | "command": "xdg-open" 30 | } 31 | } 32 | }, 33 | { 34 | "name": "API: Attach", 35 | "type": "coreclr", 36 | "request": "attach", 37 | "processId": "${command:pickProcess}" 38 | }, 39 | { 40 | "name": "Function: Run & Attach (Debug)", 41 | "type": "coreclr", 42 | "request": "attach", 43 | "processId": "${command:azureFunctions.pickProcess}" 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /api/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "azureFunctions.deploySubpath": "PayrollProcessor.Functions.Api/bin/Release/net6.0/publish", 3 | "azureFunctions.projectLanguage": "C#", 4 | "azureFunctions.projectRuntime": "~4", 5 | "azureFunctions.preDeployTask": "publish" 6 | } 7 | -------------------------------------------------------------------------------- /api/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | latest 4 | enable 5 | CS8600;CS8602;CS8603 6 | 7 | 8 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain.Tests/Features/Employees/EmployeeCreateCommandTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using AutoFixture.Idioms; 3 | using FluentAssertions; 4 | using PayrollProcessor.Core.Domain.Features.Employees; 5 | using PayrollProcessor.Tests.Fixtures; 6 | using Xunit; 7 | 8 | namespace PayrollProcessor.Core.Domain.Tests.Features.Employees; 9 | 10 | public class EmployeeCreateCommandTests 11 | { 12 | [Fact] 13 | public void Constructor_Guards_Against_Invalid_Parameters() 14 | { 15 | var assertion = new GuardClauseAssertion(new DomainFixture()); 16 | 17 | assertion.Verify(typeof(EmployeeCreateCommand).GetConstructors()); 18 | } 19 | 20 | [Theory, AutoDomainData] 21 | public void Constructor_Assigns_Properties_From_Parameters(Guid newId, EmployeeNew employee) 22 | { 23 | var sut = new EmployeeCreateCommand(newId, employee); 24 | 25 | sut.NewId.Should().Be(newId); 26 | sut.Employee.Should().Be(employee); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain.Tests/Features/Employees/EmployeePayrollCreateCommandTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using AutoFixture.Idioms; 3 | using FluentAssertions; 4 | using PayrollProcessor.Core.Domain.Features.Employees; 5 | using PayrollProcessor.Tests.Fixtures; 6 | using Xunit; 7 | 8 | namespace PayrollProcessor.Core.Domain.Tests.Features.Employees; 9 | 10 | public class EmployeePayrollCreateCommandTests 11 | { 12 | [Fact] 13 | public void ConstructorGuardsAgainstInvalidParameters() 14 | { 15 | var assertion = new GuardClauseAssertion(new DomainFixture()); 16 | 17 | assertion.Verify(typeof(EmployeePayrollCreateCommand).GetConstructors()); 18 | } 19 | 20 | [Theory, AutoDomainData] 21 | public void ConstructorAssignsPropertiesFromParameters(Guid newPayrollId, Employee employee, EmployeePayrollNew employeePayroll) 22 | { 23 | var sut = new EmployeePayrollCreateCommand(employee, newPayrollId, employeePayroll); 24 | 25 | sut.Employee.Should().Be(employee); 26 | sut.NewPayrollId.Should().Be(newPayrollId); 27 | sut.NewPayroll.Should().Be(employeePayroll); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain.Tests/PayrollProcessor.Core.Domain.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net6.0 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Departments/DepartmentEmployee.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace PayrollProcessor.Core.Domain.Features.Departments; 4 | 5 | public class DepartmentEmployee 6 | { 7 | public DepartmentEmployee(Guid id) => Id = id; 8 | 9 | public Guid Id { get; } 10 | public Guid EmployeeId { get; set; } 11 | public string Department { get; set; } = ""; 12 | public string FirstName { get; set; } = ""; 13 | public string LastName { get; set; } = ""; 14 | public string Email { get; set; } = ""; 15 | public string Version { get; set; } = ""; 16 | } 17 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Departments/DepartmentEmployeeCreateCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Ardalis.GuardClauses; 3 | using PayrollProcessor.Core.Domain.Features.Employees; 4 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Commands; 5 | 6 | namespace PayrollProcessor.Core.Domain.Features.Departments; 7 | 8 | public class DepartmentEmployeeCreateCommand : ICommand 9 | { 10 | public Employee Employee { get; } 11 | public Guid RecordId { get; } 12 | 13 | public DepartmentEmployeeCreateCommand(Employee employee, Guid recordId) 14 | { 15 | Guard.Against.Null(employee, nameof(employee)); 16 | Guard.Against.Default(recordId, nameof(recordId)); 17 | 18 | this.RecordId = recordId; 19 | this.Employee = employee; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Departments/DepartmentEmployeeQuery.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Ardalis.GuardClauses; 3 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Queries; 4 | 5 | namespace PayrollProcessor.Core.Domain.Features.Departments; 6 | 7 | public class DepartmentEmployeeQuery : IQuery 8 | { 9 | public string Department { get; } 10 | public Guid EmployeeId { get; } 11 | public DepartmentEmployeeQuery(string department, Guid employeeId) 12 | { 13 | Guard.Against.NullOrWhiteSpace(department, nameof(department)); 14 | Guard.Against.Default(employeeId, nameof(employeeId)); 15 | 16 | EmployeeId = employeeId; 17 | Department = department; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Departments/DepartmentEmployeeUpdateCommand.cs: -------------------------------------------------------------------------------- 1 | using Ardalis.GuardClauses; 2 | using PayrollProcessor.Core.Domain.Features.Employees; 3 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Commands; 4 | 5 | namespace PayrollProcessor.Core.Domain.Features.Departments; 6 | 7 | public class DepartmentEmployeeUpdateCommand : ICommand 8 | { 9 | public Employee Employee { get; } 10 | public DepartmentEmployee DepartmentEmployee { get; } 11 | 12 | public DepartmentEmployeeUpdateCommand(Employee employee, DepartmentEmployee departmentEmployee) 13 | { 14 | Guard.Against.Null(employee, nameof(employee)); 15 | Guard.Against.Null(departmentEmployee, nameof(departmentEmployee)); 16 | 17 | Employee = employee; 18 | DepartmentEmployee = departmentEmployee; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Departments/DepartmentEmployeesQuery.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Ardalis.GuardClauses; 4 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Queries; 5 | 6 | namespace PayrollProcessor.Core.Domain.Features.Departments; 7 | 8 | public class DepartmentEmployeesQuery : IQuery> 9 | { 10 | public string Department { get; } 11 | public int Count { get; } 12 | 13 | public DepartmentEmployeesQuery(int count, string department) 14 | { 15 | Guard.Against.NullOrWhiteSpace(department, nameof(department)); 16 | 17 | Count = count; 18 | Department = department; 19 | } 20 | 21 | public void Deconstruct(out int count, out string department) 22 | { 23 | count = Count; 24 | department = Department; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Departments/DepartmentPayroll.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace PayrollProcessor.Core.Domain.Features.Departments; 4 | 5 | public class DepartmentPayroll 6 | { 7 | public Guid Id { get; set; } 8 | public Guid EmployeePayrollId { get; set; } 9 | public DateTimeOffset CheckDate { get; set; } 10 | public Guid EmployeeId { get; set; } 11 | public decimal GrossPayroll { get; set; } 12 | public string PayrollPeriod { get; set; } = ""; 13 | public string EmployeeDepartment { get; set; } = ""; 14 | public string EmployeeFirstName { get; set; } = ""; 15 | public string EmployeeLastName { get; set; } = ""; 16 | public string Version { get; set; } = ""; 17 | 18 | public DepartmentPayroll(Guid id) => Id = id; 19 | } 20 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Departments/DepartmentPayrollCreateCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Ardalis.GuardClauses; 3 | using PayrollProcessor.Core.Domain.Features.Employees; 4 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Commands; 5 | 6 | namespace PayrollProcessor.Core.Domain.Features.Departments; 7 | 8 | public class DepartmentPayrollCreateCommand : ICommand 9 | { 10 | public Employee Employee { get; } 11 | public EmployeePayroll EmployeePayroll { get; } 12 | public Guid RecordId { get; } 13 | 14 | public DepartmentPayrollCreateCommand(Employee employee, Guid recordId, EmployeePayroll employeePayroll) 15 | { 16 | Guard.Against.Default(recordId, nameof(recordId)); 17 | Guard.Against.Null(employee, nameof(employee)); 18 | Guard.Against.Null(employeePayroll, nameof(employeePayroll)); 19 | 20 | RecordId = recordId; 21 | EmployeePayroll = employeePayroll; 22 | Employee = employee; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Departments/DepartmentPayrollQuery.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Ardalis.GuardClauses; 3 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Queries; 4 | 5 | namespace PayrollProcessor.Core.Domain.Features.Departments; 6 | 7 | public class DepartmentPayrollQuery : IQuery 8 | { 9 | public string Department { get; } 10 | public Guid EmployeePayrollId { get; } 11 | 12 | public DepartmentPayrollQuery(string department, Guid employeePayrollId) 13 | { 14 | Guard.Against.NullOrWhiteSpace(department, nameof(department)); 15 | Guard.Against.Default(employeePayrollId, nameof(employeePayrollId)); 16 | 17 | EmployeePayrollId = employeePayrollId; 18 | Department = department; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Departments/DepartmentPayrollUpdateCommand.cs: -------------------------------------------------------------------------------- 1 | using Ardalis.GuardClauses; 2 | using PayrollProcessor.Core.Domain.Features.Employees; 3 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Commands; 4 | 5 | namespace PayrollProcessor.Core.Domain.Features.Departments; 6 | 7 | public class DepartmentPayrollUpdateCommand : ICommand 8 | { 9 | public Employee Employee { get; } 10 | public EmployeePayroll EmployeePayroll { get; } 11 | public DepartmentPayroll DepartmentPayroll { get; } 12 | 13 | public DepartmentPayrollUpdateCommand(Employee employee, EmployeePayroll employeePayroll, DepartmentPayroll departmentPayroll) 14 | { 15 | Guard.Against.Null(employee, nameof(employee)); 16 | Guard.Against.Null(employeePayroll, nameof(employeePayroll)); 17 | Guard.Against.Null(departmentPayroll, nameof(departmentPayroll)); 18 | 19 | EmployeePayroll = employeePayroll; 20 | DepartmentPayroll = departmentPayroll; 21 | Employee = employee; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Departments/DepartmentPayrollsQuery.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Ardalis.GuardClauses; 4 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Queries; 5 | 6 | namespace PayrollProcessor.Core.Domain.Features.Departments; 7 | 8 | public class DepartmentPayrollsQuery : IQuery> 9 | { 10 | public int Count { get; } 11 | public string Department { get; } 12 | public DateTime? CheckDateFrom { get; } 13 | public DateTime? CheckDateTo { get; } 14 | 15 | public DepartmentPayrollsQuery( 16 | int count, 17 | string department, 18 | DateTime? checkDateFrom, 19 | DateTime? checkDateTo) 20 | { 21 | Guard.Against.NullOrWhiteSpace(department, nameof(department)); 22 | 23 | Department = department; 24 | CheckDateFrom = checkDateFrom; 25 | CheckDateTo = checkDateTo; 26 | Count = count; 27 | } 28 | 29 | public void Deconstruct(out int count, out string department, out DateTime? checkDateFrom, out DateTime? checkDateTo) 30 | { 31 | count = Count; 32 | department = Department; 33 | checkDateFrom = CheckDateFrom; 34 | checkDateTo = CheckDateTo; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Employees/Employee.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace PayrollProcessor.Core.Domain.Features.Employees; 5 | 6 | public class Employee 7 | { 8 | public Guid Id { get; set; } 9 | public string Department { get; set; } = ""; 10 | public string Email { get; set; } = ""; 11 | public DateTimeOffset EmploymentStartedOn { get; set; } 12 | public string FirstName { get; set; } = ""; 13 | public string LastName { get; set; } = ""; 14 | public string Phone { get; set; } = ""; 15 | public string Status { get; set; } = ""; 16 | public string Title { get; set; } = ""; 17 | public string Version { get; set; } = ""; 18 | 19 | public Employee(Guid id) => Id = id; 20 | } 21 | 22 | public class EmployeeDetail : Employee 23 | { 24 | public EmployeeDetail(Guid id) : base(id) 25 | { 26 | 27 | } 28 | 29 | public IEnumerable Payrolls { get; set; } = new EmployeePayroll[] { }; 30 | } 31 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Employees/EmployeeCreateCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Ardalis.GuardClauses; 3 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Commands; 4 | 5 | namespace PayrollProcessor.Core.Domain.Features.Employees; 6 | 7 | public class EmployeeCreateCommand : ICommand 8 | { 9 | public Guid NewId { get; set; } 10 | public EmployeeNew Employee { get; } 11 | 12 | public EmployeeCreateCommand(Guid newId, EmployeeNew employee) 13 | { 14 | Guard.Against.Default(newId, nameof(newId)); 15 | Guard.Against.Null(employee, nameof(employee)); 16 | 17 | NewId = newId; 18 | Employee = employee; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Employees/EmployeeDepartment.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | 5 | namespace PayrollProcessor.Core.Domain.Features.Employees; 6 | 7 | public class EmployeeDepartment 8 | { 9 | public string CodeName { get; set; } 10 | public string DisplayName { get; set; } 11 | 12 | protected EmployeeDepartment(string codeName, string displayName) 13 | { 14 | CodeName = codeName; 15 | DisplayName = displayName; 16 | } 17 | 18 | public static readonly EmployeeDepartment Human_Resources = new EmployeeDepartment(nameof(Human_Resources), "Human Resources"); 19 | public static readonly EmployeeDepartment IT = new EmployeeDepartment(nameof(IT), "IT"); 20 | public static readonly EmployeeDepartment Sales = new EmployeeDepartment(nameof(Sales), "Sales"); 21 | public static readonly EmployeeDepartment Building_Services = new EmployeeDepartment(nameof(Building_Services), "Building Services"); 22 | public static readonly EmployeeDepartment Marketing = new EmployeeDepartment(nameof(Marketing), "Marketing"); 23 | public static readonly EmployeeDepartment Warehouse = new EmployeeDepartment(nameof(Warehouse), "Warehouse"); 24 | 25 | public static readonly EmployeeDepartment Unknown = new EmployeeDepartment(nameof(Unknown), "Unknown"); 26 | 27 | public static readonly IEnumerable All = new[] 28 | { 29 | Human_Resources, 30 | IT, 31 | Sales, 32 | Building_Services, 33 | Marketing, 34 | Warehouse 35 | }; 36 | 37 | public static EmployeeDepartment Find(string codeName) => 38 | All.FirstOrDefault(s => s.CodeName.Equals(codeName, StringComparison.OrdinalIgnoreCase)) ?? Unknown; 39 | } 40 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Employees/EmployeeDetailQuery.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Ardalis.GuardClauses; 3 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Queries; 4 | 5 | namespace PayrollProcessor.Core.Domain.Features.Employees; 6 | 7 | public class EmployeeDetailQuery : IQuery 8 | { 9 | public Guid EmployeeId { get; } 10 | 11 | public EmployeeDetailQuery(Guid employeeId) 12 | { 13 | Guard.Against.Default(employeeId, nameof(employeeId)); 14 | 15 | EmployeeId = employeeId; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Employees/EmployeeNew.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace PayrollProcessor.Core.Domain.Features.Employees; 4 | 5 | public class EmployeeNew 6 | { 7 | public string Department { get; set; } = ""; 8 | public string Email { get; set; } = ""; 9 | public DateTimeOffset EmploymentStartedOn { get; set; } 10 | public string FirstName { get; set; } = ""; 11 | public string LastName { get; set; } = ""; 12 | public string Phone { get; set; } = ""; 13 | public string Status { get; set; } = ""; 14 | public string Title { get; set; } = ""; 15 | } 16 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Employees/EmployeePayroll.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace PayrollProcessor.Core.Domain.Features.Employees; 4 | 5 | public class EmployeePayroll 6 | { 7 | public Guid Id { get; set; } 8 | public Guid EmployeeId { get; set; } 9 | public DateTimeOffset CheckDate { get; set; } 10 | public decimal GrossPayroll { get; set; } 11 | public string PayrollPeriod { get; set; } = ""; 12 | public string Version { get; set; } = ""; 13 | 14 | public EmployeePayroll(Guid id) => Id = id; 15 | } 16 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Employees/EmployeePayrollCreateCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Ardalis.GuardClauses; 3 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Commands; 4 | 5 | namespace PayrollProcessor.Core.Domain.Features.Employees; 6 | 7 | public class EmployeePayrollCreateCommand : ICommand 8 | { 9 | public Employee Employee { get; } 10 | public EmployeePayrollNew NewPayroll { get; } 11 | public Guid NewPayrollId { get; } 12 | 13 | public EmployeePayrollCreateCommand(Employee employee, Guid newPayrollId, EmployeePayrollNew newPayroll) 14 | { 15 | Guard.Against.Null(employee, nameof(employee)); 16 | Guard.Against.Null(newPayroll, nameof(newPayroll)); 17 | Guard.Against.Default(newPayrollId, nameof(newPayrollId)); 18 | 19 | NewPayroll = newPayroll; 20 | NewPayrollId = newPayrollId; 21 | Employee = employee; 22 | } 23 | 24 | public void Deconstruct(out Employee employee, out Guid newPayrollId, out EmployeePayrollNew newPayroll) 25 | { 26 | employee = Employee; 27 | newPayrollId = NewPayrollId; 28 | newPayroll = NewPayroll; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Employees/EmployeePayrollNew.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | 4 | namespace PayrollProcessor.Core.Domain.Features.Employees; 5 | 6 | public class EmployeePayrollNew 7 | { 8 | public DateTimeOffset CheckDate { get; set; } 9 | public decimal GrossPayroll { get; set; } 10 | public string PayrollPeriod => (ISOWeek.GetWeekOfYear(CheckDate.DateTime) / 2).ToString().PadLeft(2, '0'); 11 | } 12 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Employees/EmployeePayrollQuery.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Ardalis.GuardClauses; 3 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Queries; 4 | 5 | namespace PayrollProcessor.Core.Domain.Features.Employees; 6 | 7 | public class EmployeePayrollQuery : IQuery 8 | { 9 | public Guid EmployeeId { get; } 10 | public Guid EmployeePayrollId { get; } 11 | public EmployeePayrollQuery(Guid employeeId, Guid employeePayrollId) 12 | { 13 | Guard.Against.Null(employeeId, nameof(employeeId)); 14 | Guard.Against.Null(employeePayrollId, nameof(employeePayrollId)); 15 | 16 | EmployeePayrollId = employeePayrollId; 17 | EmployeeId = employeeId; 18 | } 19 | 20 | public void Deconstruct(out Guid employeeId, out Guid employeePayrollId) 21 | { 22 | employeeId = EmployeeId; 23 | employeePayrollId = EmployeePayrollId; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Employees/EmployeePayrollUpdateCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Ardalis.GuardClauses; 3 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Commands; 4 | 5 | namespace PayrollProcessor.Core.Domain.Features.Employees; 6 | 7 | public class EmployeePayrollUpdateCommand : ICommand 8 | { 9 | public Guid EmployeeId { get; } 10 | public Guid EmployeePayrollId { get; } 11 | public DateTimeOffset CheckDate { get; } 12 | public decimal GrossPayroll { get; } 13 | public string PayrollPeriod { get; } 14 | public EmployeePayroll EntityToUpdate { get; } 15 | 16 | public EmployeePayrollUpdateCommand(DateTimeOffset checkDate, decimal grossPayroll, string payrollPeriod, EmployeePayroll entityToUpdate) 17 | { 18 | Guard.Against.Null(entityToUpdate, nameof(entityToUpdate)); 19 | 20 | EntityToUpdate = entityToUpdate; 21 | PayrollPeriod = payrollPeriod; 22 | GrossPayroll = grossPayroll; 23 | CheckDate = checkDate; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Employees/EmployeeQuery.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Ardalis.GuardClauses; 3 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Queries; 4 | 5 | namespace PayrollProcessor.Core.Domain.Features.Employees; 6 | 7 | public class EmployeeQuery : IQuery 8 | { 9 | public Guid EmployeeId { get; } 10 | 11 | public EmployeeQuery(Guid employeeId) 12 | { 13 | Guard.Against.Default(employeeId, nameof(employeeId)); 14 | 15 | EmployeeId = employeeId; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Employees/EmployeeStatus.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | 5 | namespace PayrollProcessor.Core.Domain.Features.Employees; 6 | 7 | public class EmployeeStatus 8 | { 9 | public string CodeName { get; } 10 | public string DisplayName { get; } 11 | 12 | protected EmployeeStatus(string codeName, string displayName) 13 | { 14 | CodeName = codeName; 15 | DisplayName = displayName; 16 | } 17 | 18 | public static readonly EmployeeStatus Enabled = new EmployeeStatus(nameof(Enabled), "Enabled"); 19 | public static readonly EmployeeStatus Disabled = new EmployeeStatus(nameof(Disabled), "Disabled"); 20 | public static readonly EmployeeStatus Unknown = new EmployeeStatus(nameof(Unknown), "Unknown"); 21 | 22 | public static readonly IEnumerable All = new[] { Enabled, Disabled }; 23 | 24 | public static EmployeeStatus Find(string codeName) => 25 | All.FirstOrDefault(s => s.CodeName.Equals(codeName, StringComparison.OrdinalIgnoreCase)) ?? Unknown; 26 | } 27 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Employees/EmployeeUpdateCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Ardalis.GuardClauses; 3 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Commands; 4 | 5 | namespace PayrollProcessor.Core.Domain.Features.Employees; 6 | 7 | public class EmployeeUpdateCommand : ICommand 8 | { 9 | public string Email { get; } = ""; 10 | public DateTimeOffset EmploymentStartedOn { get; } 11 | public string FirstName { get; } = ""; 12 | public string LastName { get; } = ""; 13 | public string Phone { get; } = ""; 14 | public string Status { get; } = ""; 15 | public string Title { get; } = ""; 16 | public string Version { get; } = ""; 17 | public Employee EntityToUpdate { get; } 18 | 19 | public EmployeeUpdateCommand( 20 | string email, 21 | DateTimeOffset employmentStartedOn, 22 | string firstName, 23 | string lastName, 24 | string phone, 25 | string status, 26 | string title, 27 | string version, 28 | Employee entityToUpdate) 29 | { 30 | Guard.Against.NullOrWhiteSpace(version, nameof(version)); 31 | Guard.Against.Null(entityToUpdate, nameof(entityToUpdate)); 32 | 33 | Email = email; 34 | EmploymentStartedOn = employmentStartedOn; 35 | FirstName = firstName; 36 | LastName = lastName; 37 | Phone = phone; 38 | Status = status; 39 | Title = title; 40 | Version = version; 41 | EntityToUpdate = entityToUpdate; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Employees/EmployeesQuery.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Queries; 3 | 4 | namespace PayrollProcessor.Core.Domain.Features.Employees; 5 | 6 | public class EmployeesQuery : IQuery> 7 | { 8 | public int Count { get; } 9 | public string FirstName { get; } 10 | public string LastName { get; } 11 | public string Email { get; } 12 | 13 | public EmployeesQuery(int count, string email, string firstName, string lastName) 14 | { 15 | Email = email; 16 | LastName = lastName; 17 | FirstName = firstName; 18 | Count = count; 19 | 20 | } 21 | 22 | public void Deconstruct(out int count, out string email, out string firstName, out string lastName) 23 | { 24 | count = Count; 25 | email = Email; 26 | firstName = FirstName; 27 | lastName = LastName; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Features/Resources/ResourceCountQuery.cs: -------------------------------------------------------------------------------- 1 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Queries; 2 | 3 | namespace PayrollProcessor.Core.Domain.Features.Employees; 4 | 5 | public class ResourceCountQuery : IQuery { } 6 | 7 | public class ResourceCountQueryResponse 8 | { 9 | public ResourceCountQueryResponse(int totalEmployees, int totalPayrolls) 10 | { 11 | TotalEmployees = totalEmployees; 12 | TotalPayrolls = totalPayrolls; 13 | } 14 | 15 | public int TotalEmployees { get; } 16 | public int TotalPayrolls { get; } 17 | } 18 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Intrastructure/Clocks/DateTimeProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace PayrollProcessor.Core.Domain.Intrastructure.Clocks; 4 | 5 | public interface IDateTimeProvider 6 | { 7 | DateTime UtcNow(); 8 | DateTime Now(); 9 | } 10 | 11 | public class DateTimeProvider : IDateTimeProvider 12 | { 13 | public DateTime Now() => DateTime.Now; 14 | 15 | public DateTime UtcNow() => DateTime.UtcNow; 16 | } 17 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Intrastructure/Identifiers/EntityIdGenerator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace PayrollProcessor.Core.Domain.Intrastructure.Identifiers; 4 | 5 | public interface IEntityIdGenerator 6 | { 7 | Guid Generate(); 8 | } 9 | 10 | public class EntityIdGenerator : IEntityIdGenerator 11 | { 12 | public Guid Generate() => Guid.NewGuid(); 13 | } 14 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Intrastructure/LanguageExtensions/TryAsyncLanguageExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace LanguageExt; 4 | 5 | public static class TryAsyncLanguageExtensions 6 | { 7 | /// 8 | /// Performs the given action if the operation results in a failed state 9 | /// 10 | /// Current operation 11 | /// Action to execute with the exception that caused the failure 12 | /// 13 | /// 14 | public static TryAsync DoIfFail(this TryAsync t, Action ifFail) => async () => 15 | { 16 | var optionResult = await t.Try(); 17 | 18 | if (optionResult.IsFaulted) 19 | { 20 | optionResult.IfFail(ifFail); 21 | } 22 | 23 | return optionResult; 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Intrastructure/LanguageExtensions/TryOptionAsyncLanguageExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace LanguageExt; 4 | 5 | public static class TryOptionAsyncLanguageExtensions 6 | { 7 | /// 8 | /// Performs the given action if the operation results in a failed state 9 | /// 10 | /// Current operation 11 | /// Action to execute with the exception that caused the failure 12 | /// 13 | /// 14 | public static TryOptionAsync DoIfFail(this TryOptionAsync t, Action ifFail) => async () => 15 | { 16 | var optionResult = await t.Try(); 17 | 18 | if (optionResult.IsFaulted) 19 | { 20 | optionResult.IfFail(ifFail); 21 | } 22 | 23 | return optionResult; 24 | }; 25 | 26 | /// 27 | /// Performs (at most) one of the given actions if the operation results in a None value or a failed state 28 | /// 29 | /// 30 | /// Action to execute if the operation results in no value 31 | /// Action to execute if the operation fails, with the exception that caused the failure 32 | /// 33 | /// 34 | public static TryOptionAsync DoIfNoneOrFail(this TryOptionAsync t, Action ifNone, Action ifFail) => async () => 35 | { 36 | var optionResult = await t.Try(); 37 | 38 | if (optionResult.IsFaulted) 39 | { 40 | optionResult.IfFail(ifFail); 41 | } 42 | else if (optionResult.IsNone) 43 | { 44 | optionResult.IfFailOrNone(ifNone); 45 | } 46 | 47 | return optionResult; 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Intrastructure/Operations/Commands/ICommand.cs: -------------------------------------------------------------------------------- 1 | namespace PayrollProcessor.Core.Domain.Intrastructure.Operations.Commands; 2 | 3 | public interface ICommand { } 4 | 5 | public interface ICommand { } 6 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Intrastructure/Operations/Commands/ICommandDispatcher.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using LanguageExt; 3 | 4 | namespace PayrollProcessor.Core.Domain.Intrastructure.Operations.Commands; 5 | 6 | public interface ICommandDispatcher 7 | { 8 | TryAsync Dispatch(ICommand command, CancellationToken token = default); 9 | 10 | TryAsync Dispatch(ICommand command, CancellationToken token = default); 11 | } 12 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Intrastructure/Operations/Commands/ICommandHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using LanguageExt; 3 | 4 | namespace PayrollProcessor.Core.Domain.Intrastructure.Operations.Commands; 5 | 6 | public interface ICommandHandler where TCommand : ICommand 7 | { 8 | TryAsync Execute(TCommand command, CancellationToken token); 9 | } 10 | 11 | public interface ICommandHandler where TCommand : ICommand 12 | { 13 | TryAsync Execute(TCommand command, CancellationToken token); 14 | } 15 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Intrastructure/Operations/Factories/HandlerBase.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace PayrollProcessor.Core.Domain.Intrastructure.Operations.Factories; 4 | 5 | internal abstract class HandlerBase 6 | { 7 | protected static THandler GetHandler(ServiceProviderDelegate serviceProvider) 8 | { 9 | THandler handler; 10 | 11 | try 12 | { 13 | handler = serviceProvider.GetInstance(); 14 | } 15 | catch (Exception e) 16 | { 17 | throw new InvalidOperationException($"Error constructing handler for request of type {typeof(THandler)}. Register your handlers with the container.", e); 18 | } 19 | 20 | if (handler is null) 21 | { 22 | throw new InvalidOperationException($"Handler was not found for request of type {typeof(THandler)}. Register your handlers with the container."); 23 | } 24 | 25 | return handler; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Intrastructure/Operations/Factories/ServiceProviderDelegate.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace PayrollProcessor.Core.Domain.Intrastructure.Operations.Factories; 4 | 5 | public delegate object ServiceProviderDelegate(Type serviceType); 6 | 7 | public static class ServiceFactoryExtensions 8 | { 9 | public static T GetInstance(this ServiceProviderDelegate serviceProvider) 10 | => (T)serviceProvider(typeof(T)); 11 | } 12 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Intrastructure/Operations/Queries/IQuery.cs: -------------------------------------------------------------------------------- 1 | namespace PayrollProcessor.Core.Domain.Intrastructure.Operations.Queries; 2 | 3 | public interface IQuery 4 | { 5 | } 6 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Intrastructure/Operations/Queries/IQueryDispatcher.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using LanguageExt; 3 | 4 | namespace PayrollProcessor.Core.Domain.Intrastructure.Operations.Queries; 5 | 6 | public interface IQueryDispatcher 7 | { 8 | TryOptionAsync Dispatch(IQuery query, CancellationToken token = default); 9 | } 10 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Intrastructure/Operations/Queries/IQueryHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using LanguageExt; 3 | 4 | namespace PayrollProcessor.Core.Domain.Intrastructure.Operations.Queries; 5 | 6 | public interface IQueryHandler where TQuery : IQuery 7 | { 8 | TryOptionAsync Execute(TQuery query, CancellationToken token); 9 | } 10 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/Intrastructure/Serialization/DefaultJsonSerializerSettings.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using Newtonsoft.Json.Serialization; 3 | 4 | namespace PayrollProcessor.Core.Domain.Infrastructure.Serialization; 5 | 6 | public static class DefaultJsonSerializerSettings 7 | { 8 | public static JsonSerializerSettings JsonSerializerSettings { get; } = 9 | new JsonSerializerSettings 10 | { 11 | ContractResolver = new CamelCasePropertyNamesContractResolver() 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Core.Domain/PayrollProcessor.Core.Domain.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Data.Persistence.Tests/PayrollProcessor.Data.Persistence.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Data.Persistence/Features/Departments/DepartmentEmployeeCreateCommandHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using Ardalis.GuardClauses; 3 | using LanguageExt; 4 | using Microsoft.Azure.Cosmos; 5 | using PayrollProcessor.Core.Domain.Features.Departments; 6 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Commands; 7 | using static LanguageExt.Prelude; 8 | 9 | namespace PayrollProcessor.Data.Persistence.Features.Departments; 10 | 11 | public class DepartmentEmployeeCreateCommandHandler : ICommandHandler 12 | { 13 | private readonly CosmosClient client; 14 | 15 | public DepartmentEmployeeCreateCommandHandler(CosmosClient client) 16 | { 17 | Guard.Against.Null(client, nameof(client)); 18 | 19 | this.client = client; 20 | } 21 | 22 | public TryAsync Execute(DepartmentEmployeeCreateCommand command, CancellationToken token) => 23 | DepartmentEmployeeRecord 24 | .Map 25 | .CreateNewFrom(command.Employee, command.RecordId) 26 | .Apply(record => client 27 | .GetDepartmentsContainer() 28 | .CreateItemAsync(record, cancellationToken: token)) 29 | .Apply(TryAsync) 30 | .Map(CosmosResponse.Unwrap) 31 | .Map(DepartmentEmployeeRecord.Map.ToDepartmentEmployee); 32 | } 33 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Data.Persistence/Features/Departments/DepartmentEmployeeQueryHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Threading; 3 | using Ardalis.GuardClauses; 4 | using LanguageExt; 5 | using Microsoft.Azure.Cosmos; 6 | using Microsoft.Azure.Cosmos.Linq; 7 | using PayrollProcessor.Core.Domain.Features.Departments; 8 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Queries; 9 | 10 | using static LanguageExt.Prelude; 11 | 12 | namespace PayrollProcessor.Data.Persistence.Features.Departments; 13 | 14 | public class DepartmentEmployeeQueryHandler : IQueryHandler 15 | { 16 | private readonly CosmosClient client; 17 | 18 | public DepartmentEmployeeQueryHandler(CosmosClient client) 19 | { 20 | Guard.Against.Null(client, nameof(client)); 21 | 22 | this.client = client; 23 | } 24 | 25 | public TryOptionAsync Execute(DepartmentEmployeeQuery query, CancellationToken token) 26 | { 27 | var dataQuery = client.GetDepartmentsContainer() 28 | .GetItemLinqQueryable( 29 | requestOptions: new QueryRequestOptions 30 | { 31 | PartitionKey = new PartitionKey(query.Department.ToLowerInvariant()) 32 | }) 33 | .Where(e => e.EmployeeId == query.EmployeeId); 34 | 35 | return async () => 36 | { 37 | var iterator = dataQuery.ToFeedIterator(); 38 | 39 | while (iterator.HasMoreResults) 40 | { 41 | foreach (var result in await iterator.ReadNextAsync(token)) 42 | { 43 | return DepartmentEmployeeRecord.Map.ToDepartmentEmployee(result); 44 | } 45 | } 46 | 47 | return None; 48 | }; 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Data.Persistence/Features/Departments/DepartmentEmployeesQueryHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Threading; 4 | using Ardalis.GuardClauses; 5 | using LanguageExt; 6 | using Microsoft.Azure.Cosmos; 7 | using Microsoft.Azure.Cosmos.Linq; 8 | using PayrollProcessor.Core.Domain.Features.Departments; 9 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Queries; 10 | 11 | using static LanguageExt.Prelude; 12 | 13 | namespace PayrollProcessor.Data.Persistence.Features.Departments; 14 | 15 | public class DepartmentEmployeesQueryHandler : IQueryHandler> 16 | { 17 | private readonly CosmosClient client; 18 | 19 | public DepartmentEmployeesQueryHandler(CosmosClient client) 20 | { 21 | Guard.Against.Null(client, nameof(client)); 22 | 23 | this.client = client; 24 | } 25 | 26 | public TryOptionAsync> Execute(DepartmentEmployeesQuery query, CancellationToken token = default) 27 | { 28 | var (count, department) = query; 29 | 30 | var dataQuery = client 31 | .DepartmentQueryable(department) 32 | .Where(e => e.Type == nameof(DepartmentEmployeeRecord)); 33 | 34 | if (count > 0) 35 | { 36 | dataQuery = dataQuery.Take(count); 37 | } 38 | 39 | return async () => 40 | { 41 | var iterator = dataQuery.ToFeedIterator(); 42 | 43 | var employees = new List(); 44 | 45 | while (iterator.HasMoreResults) 46 | { 47 | foreach (var result in await iterator.ReadNextAsync(token)) 48 | { 49 | employees.Add(DepartmentEmployeeRecord.Map.ToDepartmentEmployee(result)); 50 | } 51 | } 52 | 53 | return Some(employees.AsEnumerable()); 54 | }; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Data.Persistence/Features/Departments/DepartmentPayrollCreateCommandHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using Ardalis.GuardClauses; 3 | using LanguageExt; 4 | using Microsoft.Azure.Cosmos; 5 | using PayrollProcessor.Core.Domain.Features.Departments; 6 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Commands; 7 | using static LanguageExt.Prelude; 8 | 9 | namespace PayrollProcessor.Data.Persistence.Features.Departments; 10 | 11 | public class DepartmentPayrollCreateCommandHandler : ICommandHandler 12 | { 13 | private readonly CosmosClient client; 14 | 15 | public DepartmentPayrollCreateCommandHandler(CosmosClient client) 16 | { 17 | Guard.Against.Null(client, nameof(client)); 18 | 19 | this.client = client; 20 | } 21 | 22 | public TryAsync Execute(DepartmentPayrollCreateCommand command, CancellationToken token) => 23 | DepartmentPayrollRecord 24 | .Map 25 | .CreateNewFrom(command.Employee, command.RecordId, command.EmployeePayroll) 26 | .Apply(record => client 27 | .GetDepartmentsContainer() 28 | .CreateItemAsync(record, cancellationToken: token)) 29 | .Apply(TryAsync) 30 | .Map(CosmosResponse.Unwrap) 31 | .Map(DepartmentPayrollRecord.Map.ToDepartmentPayroll); 32 | } 33 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Data.Persistence/Features/Departments/DepartmentPayrollQueryHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Threading; 3 | using Ardalis.GuardClauses; 4 | using Microsoft.Azure.Cosmos; 5 | using Microsoft.Azure.Cosmos.Linq; 6 | using PayrollProcessor.Core.Domain.Features.Departments; 7 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Queries; 8 | 9 | using static LanguageExt.Prelude; 10 | 11 | namespace PayrollProcessor.Data.Persistence.Features.Departments; 12 | 13 | public class DepartmentPayrollQueryHandler : IQueryHandler 14 | { 15 | private readonly CosmosClient client; 16 | 17 | public DepartmentPayrollQueryHandler(CosmosClient client) 18 | { 19 | Guard.Against.Null(client, nameof(client)); 20 | 21 | this.client = client; 22 | } 23 | 24 | public LanguageExt.TryOptionAsync Execute(DepartmentPayrollQuery query, CancellationToken token) 25 | { 26 | var dataQuery = client.GetDepartmentsContainer() 27 | .GetItemLinqQueryable( 28 | requestOptions: new QueryRequestOptions 29 | { 30 | PartitionKey = new PartitionKey(query.Department.ToLowerInvariant()) 31 | }) 32 | .Where(e => e.EmployeePayrollId == query.EmployeePayrollId); 33 | 34 | return async () => 35 | { 36 | var iterator = dataQuery.ToFeedIterator(); 37 | 38 | while (iterator.HasMoreResults) 39 | { 40 | foreach (var result in await iterator.ReadNextAsync(token)) 41 | { 42 | return DepartmentPayrollRecord.Map.ToDepartmentPayroll(result); 43 | } 44 | } 45 | 46 | return None; 47 | }; 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Data.Persistence/Features/Departments/DepartmentPayrollUpdateCommandHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using Ardalis.GuardClauses; 3 | using LanguageExt; 4 | using Microsoft.Azure.Cosmos; 5 | using PayrollProcessor.Core.Domain.Features.Departments; 6 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Commands; 7 | using static LanguageExt.Prelude; 8 | 9 | namespace PayrollProcessor.Data.Persistence.Features.Departments; 10 | 11 | public class DepartmentPayrollUpdateCommandHandler : ICommandHandler 12 | { 13 | private readonly CosmosClient client; 14 | 15 | public DepartmentPayrollUpdateCommandHandler(CosmosClient client) 16 | { 17 | Guard.Against.Null(client, nameof(client)); 18 | 19 | this.client = client; 20 | } 21 | 22 | public TryAsync Execute(DepartmentPayrollUpdateCommand command, CancellationToken token) => 23 | DepartmentPayrollRecord 24 | .Map 25 | .Merge(command.Employee, command.EmployeePayroll, command.DepartmentPayroll) 26 | .Apply(record => client 27 | .GetDepartmentsContainer() 28 | .ReplaceItemAsync( 29 | record, record.Id.ToString(), 30 | new PartitionKey(record.PartitionKey), 31 | new ItemRequestOptions { IfMatchEtag = record.ETag }, token)) 32 | .Apply(TryAsync) 33 | .Map(CosmosResponse.Unwrap) 34 | .Map(DepartmentPayrollRecord.Map.ToDepartmentPayroll); 35 | } 36 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Data.Persistence/Features/Employees/EmployeeCreateCommandHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using Ardalis.GuardClauses; 3 | using Azure.Storage.Queues; 4 | using LanguageExt; 5 | using Microsoft.Azure.Cosmos; 6 | using PayrollProcessor.Core.Domain.Features.Employees; 7 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Commands; 8 | using PayrollProcessor.Data.Persistence.Features.Employees.QueueMessages; 9 | using PayrollProcessor.Data.Persistence.Infrastructure.Clients; 10 | using static LanguageExt.Prelude; 11 | 12 | namespace PayrollProcessor.Data.Persistence.Features.Employees; 13 | 14 | public class EmployeesCreateCommandHandler : ICommandHandler 15 | { 16 | private readonly CosmosClient client; 17 | private readonly QueueClient queueClient; 18 | 19 | public EmployeesCreateCommandHandler(CosmosClient client, IQueueClientFactory clientFactory) 20 | { 21 | Guard.Against.Null(client, nameof(client)); 22 | 23 | this.client = client; 24 | 25 | queueClient = clientFactory.Create(AppResources.Queue.EmployeeUpdates); 26 | } 27 | 28 | public TryAsync Execute(EmployeeCreateCommand command, CancellationToken token) => 29 | EmployeeRecord 30 | .Map 31 | .From(command.NewId, command.Employee) 32 | .Apply(record => client 33 | .GetEmployeesContainer() 34 | .CreateItemAsync(record, cancellationToken: token)) 35 | .Apply(TryAsync) 36 | .Map(CosmosResponse.Unwrap) 37 | .SelectMany(record => QueueMessageBuilder 38 | .ToQueueMessage(queueClient, new EmployeeCreation 39 | { 40 | EmployeeId = command.NewId 41 | }) 42 | .Apply(TryAsync), 43 | (record, _) => record) 44 | .Map(EmployeeRecord.Map.ToEmployee); 45 | } 46 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Data.Persistence/Features/Employees/EmployeePayrollCreateCommandHandler.cs: -------------------------------------------------------------------------------- 1 | 2 | using System.Threading; 3 | using Ardalis.GuardClauses; 4 | using Azure.Storage.Queues; 5 | using LanguageExt; 6 | using Microsoft.Azure.Cosmos; 7 | using PayrollProcessor.Core.Domain.Features.Employees; 8 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Commands; 9 | using PayrollProcessor.Data.Persistence.Features.Employees.QueueMessages; 10 | using PayrollProcessor.Data.Persistence.Infrastructure.Clients; 11 | using static LanguageExt.Prelude; 12 | 13 | namespace PayrollProcessor.Data.Persistence.Features.Employees; 14 | 15 | public class EmployeePayrollCreateCommandHandler : ICommandHandler 16 | { 17 | private readonly CosmosClient client; 18 | private readonly QueueClient queueClient; 19 | 20 | public EmployeePayrollCreateCommandHandler(CosmosClient client, IQueueClientFactory clientFactory) 21 | { 22 | Guard.Against.Null(client, nameof(client)); 23 | 24 | this.client = client; 25 | 26 | queueClient = clientFactory.Create(AppResources.Queue.EmployeePayrollUpdates); 27 | } 28 | 29 | public TryAsync Execute(EmployeePayrollCreateCommand command, CancellationToken token) 30 | { 31 | var (employee, newPayrollId, newEmployeePayroll) = command; 32 | 33 | return EmployeePayrollRecord 34 | .Map 35 | .From(employee, newPayrollId, newEmployeePayroll) 36 | .Apply(record => client.GetEmployeesContainer().CreateItemAsync(record, cancellationToken: token)) 37 | .Apply(TryAsync) 38 | .Map(CosmosResponse.Unwrap) 39 | .SelectMany(_ => QueueMessageBuilder 40 | .ToQueueMessage(queueClient, new EmployeePayrollCreation 41 | { 42 | EmployeeId = employee.Id, 43 | EmployeePayrollId = newPayrollId, 44 | }) 45 | .Apply(TryAsync), 46 | (record, _) => record) 47 | .Map(EmployeePayrollRecord.Map.ToEmployeePayroll); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Data.Persistence/Features/Employees/EmployeePayrollQueryHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using Ardalis.GuardClauses; 3 | using LanguageExt; 4 | using Microsoft.Azure.Cosmos; 5 | using PayrollProcessor.Core.Domain.Features.Employees; 6 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Queries; 7 | 8 | using static LanguageExt.Prelude; 9 | 10 | namespace PayrollProcessor.Data.Persistence.Features.Employees; 11 | 12 | public class EmployeePayrollQueryHandler : IQueryHandler 13 | { 14 | private readonly CosmosClient client; 15 | 16 | public EmployeePayrollQueryHandler(CosmosClient client) 17 | { 18 | Guard.Against.Null(client, nameof(client)); 19 | 20 | this.client = client; 21 | } 22 | 23 | public TryOptionAsync Execute(EmployeePayrollQuery query, CancellationToken token = default) 24 | { 25 | var container = client.GetEmployeesContainer(); 26 | 27 | var (employeeId, employeePayrollId) = query; 28 | 29 | return async () => 30 | { 31 | try 32 | { 33 | var record = await container.ReadItemAsync( 34 | employeePayrollId.ToString(), 35 | new PartitionKey(employeeId.ToString()), 36 | cancellationToken: token); 37 | 38 | return EmployeePayrollRecord.Map.ToEmployeePayroll(record); 39 | } 40 | catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) 41 | { 42 | return None; 43 | } 44 | }; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Data.Persistence/Features/Employees/EmployeeQueryHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Ardalis.GuardClauses; 5 | using LanguageExt; 6 | using Microsoft.Azure.Cosmos; 7 | using PayrollProcessor.Core.Domain.Features.Employees; 8 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Queries; 9 | 10 | using static LanguageExt.Prelude; 11 | 12 | namespace PayrollProcessor.Data.Persistence.Features.Employees; 13 | 14 | public class EmployeeQueryHandler : IQueryHandler 15 | { 16 | private readonly CosmosClient client; 17 | 18 | public EmployeeQueryHandler(CosmosClient client) 19 | { 20 | Guard.Against.Null(client, nameof(client)); 21 | 22 | this.client = client; 23 | } 24 | 25 | public TryOptionAsync Execute(EmployeeQuery query, CancellationToken token = default) 26 | { 27 | string identifier = query.EmployeeId.ToString(); 28 | 29 | return async () => 30 | { 31 | try 32 | { 33 | var record = await client 34 | .GetEmployeesContainer() 35 | .ReadItemAsync(identifier, new PartitionKey(identifier), cancellationToken: token); 36 | 37 | return EmployeeRecord.Map.ToEmployee(record); 38 | } 39 | catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) 40 | { 41 | return None; 42 | } 43 | }; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Data.Persistence/Features/Employees/QueueMessages/EmployeeCreation.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using PayrollProcessor.Data.Persistence.Infrastructure.Clients; 3 | 4 | namespace PayrollProcessor.Data.Persistence.Features.Employees.QueueMessages; 5 | 6 | public class EmployeeCreation : IMessage 7 | { 8 | public Guid EmployeeId { get; set; } 9 | public string EventName { get; set; } = nameof(EmployeeCreation); 10 | } 11 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Data.Persistence/Features/Employees/QueueMessages/EmployeePayrollCreation.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using PayrollProcessor.Data.Persistence.Infrastructure.Clients; 3 | 4 | namespace PayrollProcessor.Data.Persistence.Features.Employees.QueueMessages; 5 | 6 | public class EmployeePayrollCreation : IMessage 7 | { 8 | public Guid EmployeeId { get; set; } 9 | public Guid EmployeePayrollId { get; set; } 10 | public string EventName { get; set; } = nameof(EmployeePayrollCreation); 11 | 12 | public void Deconstruct(out Guid employeeId, out Guid employeePayrollId) 13 | { 14 | employeeId = EmployeeId; 15 | employeePayrollId = EmployeePayrollId; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Data.Persistence/Features/Employees/QueueMessages/EmployeePayrollUpdate.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using PayrollProcessor.Data.Persistence.Infrastructure.Clients; 3 | 4 | namespace PayrollProcessor.Data.Persistence.Features.Employees.QueueMessages; 5 | 6 | public class EmployeePayrollUpdate : IMessage 7 | { 8 | public Guid EmployeeId { get; set; } 9 | public Guid EmployeePayrollId { get; set; } 10 | public string EventName { get; set; } = nameof(EmployeePayrollUpdate); 11 | 12 | public void Deconstruct(out Guid employeeId, out Guid employeePayrollId) 13 | { 14 | employeeId = EmployeeId; 15 | employeePayrollId = EmployeePayrollId; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Data.Persistence/Features/Employees/QueueMessages/EmployeeUpdate.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using PayrollProcessor.Data.Persistence.Infrastructure.Clients; 3 | 4 | namespace PayrollProcessor.Data.Persistence.Features.Employees.QueueMessages; 5 | 6 | public class EmployeeUpdate : IMessage 7 | { 8 | public Guid EmployeeId { get; set; } 9 | public string EventName { get; set; } = nameof(EmployeeUpdate); 10 | } 11 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Data.Persistence/Features/Resources/ResourceCountQueryHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Ardalis.GuardClauses; 5 | using LanguageExt; 6 | using Microsoft.Azure.Cosmos; 7 | using PayrollProcessor.Core.Domain.Features.Employees; 8 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Queries; 9 | using PayrollProcessor.Data.Persistence.Features.Employees; 10 | 11 | using static LanguageExt.Prelude; 12 | 13 | namespace PayrollProcessor.Data.Persistence.Features.Resources; 14 | 15 | public class ResourceCountQueryHandler : IQueryHandler 16 | { 17 | private readonly CosmosClient client; 18 | 19 | public ResourceCountQueryHandler(CosmosClient client) 20 | { 21 | Guard.Against.Null(client, nameof(client)); 22 | 23 | this.client = client; 24 | } 25 | 26 | public TryOptionAsync Execute(ResourceCountQuery query, CancellationToken token) 27 | { 28 | var employeesCountResult = TryOptionAsync( 29 | GetResourceCount(new QueryDefinition($"SELECT VALUE COUNT(1) FROM c where c.type = '{nameof(EmployeeRecord)}'"), 30 | token)); 31 | 32 | var payrollsCountResult = TryOptionAsync( 33 | GetResourceCount(new QueryDefinition($"SELECT VALUE COUNT(1) FROM c where c.type = '{nameof(EmployeePayrollRecord)}'"), 34 | token)); 35 | 36 | return employeesCountResult.SelectMany( 37 | _ => payrollsCountResult, 38 | (employeesCount, payrollsCount) => new ResourceCountQueryResponse(employeesCount, payrollsCount)); 39 | } 40 | 41 | public async Task GetResourceCount(QueryDefinition query, CancellationToken token) 42 | { 43 | using var iterator = client.GetEmployeesContainer().GetItemQueryIterator(query); 44 | 45 | if (!iterator.HasMoreResults) 46 | { 47 | return 0; 48 | } 49 | 50 | var response = await iterator.ReadNextAsync(token); 51 | 52 | return response.Resource.First(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Data.Persistence/Infrastructure/Clients/AppResources.cs: -------------------------------------------------------------------------------- 1 | namespace PayrollProcessor.Data.Persistence.Infrastructure.Clients; 2 | 3 | public static class AppResources 4 | { 5 | public static class Queue 6 | { 7 | public const string EmployeeUpdates = "employee-updates"; 8 | public const string EmployeePayrollUpdates = "employee-payroll-updates"; 9 | } 10 | 11 | public static class CosmosDb 12 | { 13 | public const string ServiceEndpoint = "CosmosDbServiceEndpoint"; 14 | public const string AuthKey = "CosmosDbAuthKey"; 15 | 16 | public static class Databases 17 | { 18 | public static class PayrollProcessor 19 | { 20 | public const string Name = "PayrollProcessor"; 21 | 22 | public static class Containers 23 | { 24 | public const string Employees = "Employees"; 25 | public const string Departments = "Departments"; 26 | } 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Data.Persistence/Infrastructure/Clients/CosmosClientExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | 3 | using static PayrollProcessor.Data.Persistence.Infrastructure.Clients.AppResources.CosmosDb; 4 | 5 | namespace Microsoft.Azure.Cosmos; 6 | 7 | public static class CosmosClientExtensions 8 | { 9 | public static Container GetEmployeesContainer(this CosmosClient client) => 10 | client.GetContainer(Databases.PayrollProcessor.Name, Databases.PayrollProcessor.Containers.Employees); 11 | 12 | public static IOrderedQueryable EmployeesQueryable(this CosmosClient client) => 13 | client.GetEmployeesContainer().GetItemLinqQueryable(); 14 | 15 | public static IOrderedQueryable EmployeesQueryable(this CosmosClient client, string partitionKey) => 16 | client.GetEmployeesContainer().GetItemLinqQueryable(requestOptions: new QueryRequestOptions 17 | { 18 | PartitionKey = new PartitionKey(partitionKey.ToLowerInvariant()) 19 | }); 20 | 21 | public static Container GetDepartmentsContainer(this CosmosClient client) => 22 | client.GetContainer(Databases.PayrollProcessor.Name, Databases.PayrollProcessor.Containers.Departments); 23 | 24 | public static IOrderedQueryable DepartmentQueryable(this CosmosClient client) => 25 | client.GetDepartmentsContainer().GetItemLinqQueryable(); 26 | 27 | public static IOrderedQueryable DepartmentQueryable(this CosmosClient client, string partitionKey) => 28 | client.GetDepartmentsContainer().GetItemLinqQueryable(requestOptions: new QueryRequestOptions 29 | { 30 | PartitionKey = new PartitionKey(partitionKey.ToLowerInvariant()) 31 | }); 32 | } 33 | 34 | public static class CosmosResponse 35 | { 36 | public static T Unwrap(Response response) => response.Resource; 37 | } 38 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Data.Persistence/Infrastructure/Clients/QueueClientFactory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | using System.Threading.Tasks; 4 | using Ardalis.GuardClauses; 5 | using Azure; 6 | using Azure.Storage.Queues; 7 | using Azure.Storage.Queues.Models; 8 | using Newtonsoft.Json; 9 | 10 | namespace PayrollProcessor.Data.Persistence.Infrastructure.Clients; 11 | 12 | public interface IMessage 13 | { 14 | string EventName { get; set; } 15 | } 16 | 17 | public class DefaultMessage 18 | { 19 | public string EventName { get; set; } = ""; 20 | } 21 | 22 | public static class QueueMessageBuilder 23 | { 24 | public static Task> ToQueueMessage(this QueueClient client, TMessage entity) where TMessage : IMessage 25 | { 26 | /* 27 | * The Azure storage library does not base64 encode messages anymore, 28 | * however the Azure Functions bindings require base64 encoding 29 | * https://github.com/Azure/azure-sdk-for-net/issues/10242 30 | */ 31 | byte[] buffer = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(entity)); 32 | 33 | return client.SendMessageAsync(Convert.ToBase64String(buffer)); 34 | } 35 | } 36 | 37 | public interface IQueueClientFactory 38 | { 39 | QueueClient Create(string queueName); 40 | } 41 | 42 | public class QueueClientFactory : IQueueClientFactory 43 | { 44 | private readonly string connectionString; 45 | 46 | public QueueClientFactory(string connectionString) 47 | { 48 | Guard.Against.NullOrWhiteSpace(connectionString, nameof(connectionString)); 49 | 50 | this.connectionString = connectionString; 51 | } 52 | 53 | public QueueClient Create(string queueName) => new QueueClient(connectionString, queueName); 54 | } 55 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Data.Persistence/Infrastructure/Records/CosmosDbRecord.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | 4 | namespace PayrollProcessor.Data.Persistence.Infrastructure.Records; 5 | 6 | /// 7 | /// https://github.com/Azure/azure-cosmos-dotnet-v3/issues/165#issuecomment-489112642 8 | /// 9 | public abstract class CosmosDBRecord 10 | { 11 | /// 12 | /// The unique identifier of the record 13 | /// 14 | /// 15 | public Guid Id { get; set; } 16 | 17 | /// 18 | /// The partition key of the record in its collection 19 | /// 20 | /// 21 | public string PartitionKey { get; set; } = ""; 22 | 23 | /// 24 | /// The 'Version' of the record auto-managed by Cosmos and used 25 | /// for optimistic concurrency 26 | /// 27 | /// 28 | [JsonProperty(PropertyName = "_etag")] 29 | public string ETag { internal get; set; } = ""; 30 | 31 | /// 32 | /// The type discrimniator of the record, used when multiple 33 | /// different record types are stored in the same collection 34 | /// This is the name of the Record class. 35 | /// 36 | /// 37 | public string Type { get; set; } = ""; 38 | } 39 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Data.Persistence/PayrollProcessor.Data.Persistence.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Functions.Api.Tests/PayrollProcessor.Functions.Api.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | PayrollProcessor.Functions.Api.Tests 6 | 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Functions.Api/Features/Resources/ResourceManager.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Microsoft.WindowsAzure.Storage; 3 | using Microsoft.WindowsAzure.Storage.Queue; 4 | using PayrollProcessor.Functions.Api.Infrastructure; 5 | 6 | namespace PayrollProcessor.Functions.Api.Features.Resources; 7 | 8 | public class ResourceManager 9 | { 10 | private readonly CloudQueueClient queueClient; 11 | 12 | public ResourceManager() 13 | { 14 | string connectionString = EnvironmentSettings 15 | .Get("AzureWebJobsAzureTableStorage") 16 | .IfNone("UseDevelopmentStorage=true"); 17 | 18 | var storageAccount = CloudStorageAccount.Parse(connectionString); 19 | 20 | queueClient = storageAccount.CreateCloudQueueClient(); 21 | } 22 | 23 | public Task CreateQueue(string queueName) => 24 | queueClient.GetQueueReference(queueName).CreateIfNotExistsAsync(); 25 | 26 | public Task DeleteQueue(string queueName) => 27 | queueClient.GetQueueReference(queueName).DeleteIfExistsAsync(); 28 | } 29 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Functions.Api/Infrastructure/ApiClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Http; 3 | using LanguageExt; 4 | using Newtonsoft.Json; 5 | 6 | namespace PayrollProcessor.Functions.Api.Infrastructure; 7 | 8 | public interface IApiClient 9 | { 10 | TryOptionAsync SendNotification(string source, T data); 11 | } 12 | 13 | public class ApiClient : IApiClient 14 | { 15 | private readonly HttpClient client; 16 | 17 | public ApiClient(HttpClient client) 18 | { 19 | string apiDomain = EnvironmentSettings.Get("API_Domain") 20 | .IfNone(() => "http://localhost:5000"); 21 | 22 | client.BaseAddress = new Uri(apiDomain); 23 | 24 | this.client = client; 25 | } 26 | 27 | public TryOptionAsync SendNotification(string source, T data) 28 | { 29 | var notification = new Notification 30 | { 31 | Source = source, 32 | Message = JsonConvert.SerializeObject(data) 33 | }; 34 | 35 | return async () => 36 | { 37 | var response = await client.PostAsJsonAsync("/api/v1/notification", notification); 38 | 39 | response.EnsureSuccessStatusCode(); 40 | 41 | return Unit.Default; 42 | }; 43 | } 44 | } 45 | 46 | public class Notification 47 | { 48 | public string Source { get; set; } = ""; 49 | public string Message { get; set; } = ""; 50 | } 51 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Functions.Api/Infrastructure/EnvironmentSettings.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using LanguageExt; 3 | 4 | namespace PayrollProcessor.Functions.Api.Infrastructure; 5 | 6 | public static class EnvironmentSettings 7 | { 8 | public static Option Get(string name) 9 | { 10 | string? envVal = Environment.GetEnvironmentVariable(name, EnvironmentVariableTarget.Process); 11 | 12 | return string.IsNullOrWhiteSpace(envVal) 13 | ? Option.None 14 | : Option.Some(envVal); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Functions.Api/Infrastructure/QueueMessageHandler.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.WindowsAzure.Storage.Queue; 2 | using Newtonsoft.Json; 3 | using PayrollProcessor.Data.Persistence.Infrastructure.Clients; 4 | 5 | namespace PayrollProcessor.Functions.Api.Infrastructure; 6 | 7 | public class QueueMessageHandler 8 | { 9 | public static TMessage FromQueueMessage(CloudQueueMessage queueMessage) where TMessage : IMessage => 10 | #pragma warning disable CS8603 // Possible null reference return. 11 | JsonConvert.DeserializeObject(queueMessage.AsString); 12 | #pragma warning restore CS8603 // Possible null reference return. 13 | 14 | public static string GetEventName(CloudQueueMessage queueMessage) => 15 | JsonConvert.DeserializeObject(queueMessage.AsString)?.EventName ?? ""; 16 | } 17 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Functions.Api/PayrollProcessor.Functions.Api.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net6.0 4 | v4 5 | PayrollProcessor.Functions.Api 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | PreserveNewest 22 | 23 | 24 | Always 25 | Never 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Functions.Api/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingSettings": { 6 | "isEnabled": true 7 | } 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Functions.Api/local.settings.json.sample: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "AzureWebJobsStorage": "UseDevelopmentStorage=true", 5 | "CosmosDbServiceEndpoint": "https://localhost:8081", 6 | "CosmosDbAuthKey": "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==", 7 | "API_Domain": "http://localhost:5000", 8 | "FUNCTIONS_WORKER_RUNTIME": "dotnet" 9 | }, 10 | "Host": { 11 | "LocalHttpPort": 7071, 12 | "CORS": "*", 13 | "CORSCredentials": false 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Infrastructure.Seeding/Features/Generators/DomainSeed.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using PayrollProcessor.Core.Domain.Features.Departments; 5 | using PayrollProcessor.Core.Domain.Features.Employees; 6 | using PayrollProcessor.Infrastructure.Seeding.Features.Generators; 7 | 8 | namespace PayrollProcessor.Infrastructure.Seeding.Features.Employees; 9 | 10 | public class DomainSeed 11 | { 12 | private readonly EmployeeSeed employeeSeed; 13 | 14 | public DomainSeed(EmployeeSeed employeeSeed) => 15 | this.employeeSeed = employeeSeed; 16 | 17 | public IEnumerable BuildAll(int employeesCount, int payrollsMaxCount) => 18 | employeeSeed.BuildMany(employeesCount, payrollsMaxCount); 19 | } 20 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Infrastructure.Seeding/Infrastructure/DomainFaker.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Bogus; 3 | 4 | namespace PayrollProcessor.Infrastructure.Seeding.Infrastructure; 5 | 6 | /// 7 | /// Creates Fakers that use a specific factory function 8 | /// See: https://github.com/bchavez/Bogus/issues/291#issuecomment-614371450 9 | /// 10 | /// 11 | public class DomainFaker : Faker where T : class 12 | { 13 | public DomainFaker(Func factory) => 14 | CustomInstantiator(factory); 15 | } 16 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Infrastructure.Seeding/PayrollProcessor.Infrastructure.Seeding.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Tests/Fixtures/AutoDomainData.cs: -------------------------------------------------------------------------------- 1 | using AutoFixture; 2 | using AutoFixture.AutoNSubstitute; 3 | using AutoFixture.Xunit2; 4 | 5 | namespace PayrollProcessor.Tests.Fixtures; 6 | 7 | /// 8 | /// Sourced from https://tech.trailmax.info/2014/01/convert-your-projects-from-nunitmoq-to-xunit-with-nsubstitute/ 9 | /// 10 | public class AutoDomainDataAttribute : AutoDataAttribute 11 | { 12 | public AutoDomainDataAttribute() 13 | : base(() => new Fixture().Customize(new AutoNSubstituteCustomization())) 14 | { 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Tests/Fixtures/DomainFixture.cs: -------------------------------------------------------------------------------- 1 | using AutoFixture; 2 | using AutoFixture.AutoNSubstitute; 3 | 4 | namespace PayrollProcessor.Tests.Fixtures; 5 | 6 | public class DomainFixture : Fixture 7 | { 8 | public DomainFixture() : base() => 9 | Customize(new AutoNSubstituteCustomization()); 10 | } 11 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Tests/PayrollProcessor.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.1 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Web.Api.Tests/PayrollProcessor.Web.Api.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Web.Api/.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | LICENSE 25 | README.md -------------------------------------------------------------------------------- /api/PayrollProcessor.Web.Api/Dockerfile: -------------------------------------------------------------------------------- 1 | #See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. 2 | 3 | FROM mcr.microsoft.com/dotnet/core/aspnet:3.1-buster-slim AS base 4 | WORKDIR /app 5 | EXPOSE 80 6 | EXPOSE 443 7 | 8 | FROM mcr.microsoft.com/dotnet/core/sdk:3.1-buster AS build 9 | WORKDIR /src 10 | COPY ["PayrollProcessor.Web.Api.csproj", ""] 11 | COPY ["PayrollProcessor.Data.Persistence.csproj", ""] 12 | RUN dotnet restore "./PayrollProcessor.Web.Api.csproj" 13 | COPY . . 14 | WORKDIR "/src/." 15 | RUN dotnet build "PayrollProcessor.Web.Api.csproj" -c Release -o /app/build 16 | 17 | FROM build AS publish 18 | RUN dotnet publish "PayrollProcessor.Web.Api.csproj" -c Release -o /app/publish 19 | 20 | FROM base AS final 21 | WORKDIR /app 22 | COPY --from=publish /app/publish . 23 | ENTRYPOINT ["dotnet", "PayrollProcessor.Web.Api.dll"] 24 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Web.Api/Features/Employees/EmployeeGet.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Ardalis.ApiEndpoints; 5 | using Ardalis.GuardClauses; 6 | using Microsoft.AspNetCore.Mvc; 7 | using PayrollProcessor.Core.Domain.Features.Employees; 8 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Queries; 9 | using Swashbuckle.AspNetCore.Annotations; 10 | 11 | namespace PayrollProcessor.Web.Api.Features.Employees; 12 | 13 | public class EmployeeGet : EndpointBaseAsync 14 | .WithRequest 15 | .WithActionResult 16 | { 17 | private readonly IQueryDispatcher dispatcher; 18 | 19 | public EmployeeGet(IQueryDispatcher dispatcher) 20 | { 21 | Guard.Against.Null(dispatcher, nameof(dispatcher)); 22 | 23 | this.dispatcher = dispatcher; 24 | } 25 | 26 | [HttpGet("employees/{employeeId:Guid}"), MapToApiVersion("1")] 27 | [SwaggerOperation( 28 | Summary = "Gets a specific employee", 29 | Description = "Gets a specific employee specified by the route parameter with all payrolls", 30 | OperationId = "Employee.Get", 31 | Tags = new[] { "Employees" }) 32 | ] 33 | public override Task> HandleAsync(Guid employeeId, CancellationToken token) => 34 | dispatcher 35 | .Dispatch(new EmployeeDetailQuery(employeeId), token) 36 | .Match>( 37 | e => e, 38 | () => NotFound($"Employee [{employeeId}]"), 39 | ex => new APIErrorResult(ex.Message)); 40 | } 41 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Web.Api/Features/Notifications/Notification.cs: -------------------------------------------------------------------------------- 1 | namespace PayrollProcessor.Web.Api.Features.Notifications; 2 | 3 | public class Notification 4 | { 5 | public string Source { get; set; } = ""; 6 | public string Message { get; set; } = ""; 7 | } 8 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Web.Api/Features/Notifications/NotificationController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.Mvc; 4 | using Microsoft.AspNetCore.SignalR; 5 | 6 | namespace PayrollProcessor.Web.Api.Features.Notifications; 7 | 8 | [Route("notification")] 9 | [ApiController] 10 | public class NotificationController : ControllerBase 11 | { 12 | private readonly IHubContext hub; 13 | 14 | public NotificationController(IHubContext hub) 15 | { 16 | if (hub is null) 17 | { 18 | throw new ArgumentNullException(nameof(hub)); 19 | } 20 | 21 | this.hub = hub; 22 | } 23 | 24 | [HttpPost] 25 | public async Task PostMessage([FromBody] Notification notification) 26 | { 27 | await hub.Clients.All.SendAsync("received", notification); 28 | 29 | return Ok(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Web.Api/Features/Notifications/NotificationHub.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Microsoft.AspNetCore.SignalR; 3 | 4 | namespace PayrollProcessor.Web.Api.Features.Notifications; 5 | 6 | public class NotificationHub : Hub 7 | { 8 | 9 | } 10 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Web.Api/Features/Resources/ResourceStatsGet.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | using Ardalis.ApiEndpoints; 4 | using Ardalis.GuardClauses; 5 | using Microsoft.AspNetCore.Mvc; 6 | using PayrollProcessor.Core.Domain.Features.Employees; 7 | using PayrollProcessor.Core.Domain.Intrastructure.Operations.Queries; 8 | using Swashbuckle.AspNetCore.Annotations; 9 | 10 | namespace PayrollProcessor.Web.Api.Features.Resources; 11 | 12 | public class ResourceStatsGet : EndpointBaseAsync 13 | .WithoutRequest 14 | .WithActionResult 15 | { 16 | private readonly IQueryDispatcher dispatcher; 17 | 18 | public ResourceStatsGet(IQueryDispatcher dispatcher) 19 | { 20 | Guard.Against.Null(dispatcher, nameof(dispatcher)); 21 | 22 | this.dispatcher = dispatcher; 23 | } 24 | 25 | [HttpGet("resources/stats"), MapToApiVersion("1")] 26 | [SwaggerOperation( 27 | Summary = "Gets resource stats", 28 | Description = "Gets current data persistence resource stats (row counts)", 29 | OperationId = "Resource.Stats.Get", 30 | Tags = new[] { "Resources" }) 31 | ] 32 | public override Task> HandleAsync(CancellationToken token) => 33 | dispatcher 34 | .Dispatch(new ResourceCountQuery(), token) 35 | .Map(resp => new ResourceStatsResponse(resp.TotalEmployees, resp.TotalPayrolls)) 36 | .Match>( 37 | e => e, 38 | () => NotFound($"Resource stats"), 39 | ex => new APIErrorResult(ex.Message)); 40 | } 41 | 42 | public class ResourceStatsResponse 43 | { 44 | public ResourceStatsResponse(int totalEmployees, int totalPayrolls) 45 | { 46 | TotalEmployees = totalEmployees; 47 | TotalPayrolls = totalPayrolls; 48 | } 49 | 50 | public int TotalEmployees { get; } 51 | public int TotalPayrolls { get; } 52 | } 53 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Web.Api/Infrastructure/Responses/APIErrorResult.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Mvc.Infrastructure; 3 | 4 | namespace Microsoft.AspNetCore.Mvc; 5 | 6 | [DefaultStatusCode(500)] 7 | public class APIErrorResult : JsonResult 8 | { 9 | public APIErrorResult(string errorMessage = "") : base("") 10 | { 11 | string message = string.IsNullOrWhiteSpace(errorMessage) 12 | ? "Internal Server Error" 13 | : errorMessage; 14 | 15 | Value = new { Id = Guid.NewGuid(), Message = message }; 16 | 17 | StatusCode = 500; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Web.Api/Infrastructure/Responses/IListResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace PayrollProcessor.Web.Api.Infrastructure.Responses; 4 | 5 | public interface IListResponse 6 | { 7 | IEnumerable Data { get; } 8 | } 9 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Web.Api/Infrastructure/Routing/GlobalRouteConvention.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using Microsoft.AspNetCore.Mvc; 4 | using Microsoft.AspNetCore.Mvc.ApplicationModels; 5 | using Microsoft.AspNetCore.Mvc.Routing; 6 | 7 | /// 8 | /// https://stackoverflow.com/a/58406404/939634 9 | /// 10 | namespace PayrollProcessor.Web.Api.Infrastructure.Routing; 11 | 12 | public class GlobalRouteConvention : IApplicationModelConvention 13 | { 14 | private readonly AttributeRouteModel routePrefix; 15 | 16 | public GlobalRouteConvention(IRouteTemplateProvider route) 17 | { 18 | if (route is null) 19 | { 20 | throw new ArgumentNullException(nameof(route)); 21 | } 22 | 23 | routePrefix = new AttributeRouteModel(route); 24 | } 25 | 26 | public void Apply(ApplicationModel application) 27 | { 28 | foreach (var selector in application.Controllers.SelectMany(c => c.Selectors)) 29 | { 30 | if (selector.AttributeRouteModel != null) 31 | { 32 | selector.AttributeRouteModel = AttributeRouteModel.CombineAttributeRouteModel(routePrefix, selector.AttributeRouteModel); 33 | } 34 | else 35 | { 36 | selector.AttributeRouteModel = routePrefix; 37 | } 38 | } 39 | } 40 | } 41 | 42 | public static class MvcOptionsRouteExtensions 43 | { 44 | public static void UseGlobalRoutePrefix(this MvcOptions opts, IRouteTemplateProvider routeAttribute) 45 | { 46 | if (routeAttribute is null) 47 | { 48 | throw new ArgumentNullException(nameof(routeAttribute)); 49 | } 50 | 51 | opts.Conventions.Add(new GlobalRouteConvention(routeAttribute)); 52 | } 53 | 54 | public static void UseGlobalRoutePrefix(this MvcOptions opts, string prefix) 55 | { 56 | if (string.IsNullOrWhiteSpace(prefix)) 57 | { 58 | throw new ArgumentException($"{nameof(prefix)} cannot be empty", nameof(prefix)); 59 | } 60 | 61 | opts.UseGlobalRoutePrefix(new RouteAttribute(prefix)); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Web.Api/PayrollProcessor.Web.Api.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | 0f4f7018-524c-4557-9df4-fb3de3cb27c7 6 | Linux 7 | . 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Web.Api/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:5000", 7 | "sslPort": 44310 8 | } 9 | }, 10 | "$schema": "http://json.schemastore.org/launchsettings.json", 11 | "profiles": { 12 | "IIS Express": { 13 | "commandName": "IISExpress", 14 | "launchBrowser": true, 15 | "launchUrl": "", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "PayrollProcessor.Web.Api": { 21 | "commandName": "Project", 22 | "environmentVariables": { 23 | "ASPNETCORE_ENVIRONMENT": "Development" 24 | }, 25 | "applicationUrl": "http://localhost:5000" 26 | }, 27 | "Docker": { 28 | "commandName": "Docker", 29 | "launchBrowser": true, 30 | "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/risk", 31 | "publishAllPorts": true, 32 | "useSSL": true 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Web.Api/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "AllowedHosts": "*", 10 | "CORS": { 11 | "client:domains": "http://localhost:4201;http://localhost:3000" 12 | }, 13 | "CosmosDb": { 14 | "DatabaseName": "PayrollProcessor", 15 | "ServiceEndpoint": "https://localhost:8081", 16 | "AuthKey": "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==" 17 | }, 18 | "AzureStorageQueue": { 19 | "ConnectionString": "UseDevelopmentStorage=true" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /api/PayrollProcessor.Web.Api/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "AllowedHosts": "*", 10 | "CORS": { 11 | "client:domains": "http://localhost:4201;http://localhost:3000" 12 | }, 13 | "CosmosDb": { 14 | "DatabaseName": "PayrollProcessor", 15 | "ServiceEndpoint": "https://localhost:8081", 16 | "AuthKey": "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==" 17 | }, 18 | "AzureStorageQueue": { 19 | "ConnectionString": "UseDevelopmentStorage=true" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /bicep/api.bicep: -------------------------------------------------------------------------------- 1 | param appName string 2 | param appServicePlanId string 3 | param env string 4 | param location string 5 | param team string 6 | 7 | var appServiceName = '${env}-${team}-${appName}-api-${location}' 8 | 9 | resource appService 'Microsoft.Web/sites@2021-02-01' = { 10 | name: appServiceName 11 | location: location 12 | properties: { 13 | serverFarmId: appServicePlanId 14 | siteConfig: { 15 | alwaysOn: false 16 | ftpsState: 'Disabled' 17 | netFrameworkVersion: 'v6.0' 18 | // appSettings: [ 19 | // { 20 | // 'name': 'APPINSIGHTS_INSTRUMENTATIONKEY' 21 | // 'value': appInsights.outputs.instrumentationKey 22 | // } 23 | // ] 24 | } 25 | httpsOnly: false 26 | } 27 | tags: null 28 | } 29 | -------------------------------------------------------------------------------- /bicep/main.bicep: -------------------------------------------------------------------------------- 1 | /* 2 | based on tutorial here 3 | https://docs.microsoft.com/en-us/azure/azure-resource-manager/bicep/deploy-github-actions?tabs=CLI 4 | source: 5 | https://github.com/Azure/bicep/blob/main/docs/examples/101/function-app-create/main.bicep 6 | */ 7 | 8 | // resources using the format environment-team-appName-appType-region, excluding storage account 9 | param appName string = 'payrollprocessor' 10 | param env string = 'q' 11 | param location string = resourceGroup().location 12 | param team string = 'nitrodevs' 13 | 14 | // App Service Plan 15 | var appServicePlanName = '${env}-${team}-${appName}-appservice-${location}' 16 | resource appServicePlan 'Microsoft.Web/serverfarms@2021-02-01' = { 17 | name: appServicePlanName 18 | location: resourceGroup().location 19 | sku: { 20 | name: 'F1' 21 | } 22 | kind: ('windows') 23 | tags: null 24 | } 25 | 26 | // API App Service 27 | module apiAppService './api.bicep' = { 28 | name: 'apiAppService' 29 | params: { 30 | appName: appName 31 | appServicePlanId: appServicePlan.id 32 | env: env 33 | location: resourceGroup().location 34 | team: team 35 | } 36 | } 37 | 38 | // Azure Function 39 | module functionApp './function.bicep' = { 40 | name: 'functionApp' 41 | params: { 42 | appName: appName 43 | env: env 44 | location: resourceGroup().location 45 | team: team 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /client/.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # You can see what browsers were selected by your queries by running: 6 | # npx browserslist 7 | 8 | > 0.5% 9 | last 2 versions 10 | Firefox ESR 11 | not dead 12 | not IE 9-11 # For IE 9-11 support, remove 'not'. -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | speed-measure-plugin*.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | .history/* 33 | 34 | # misc 35 | /.sass-cache 36 | /connect.lock 37 | /coverage 38 | /libpeerconnection.log 39 | npm-debug.log 40 | yarn-error.log 41 | testem.log 42 | /typings 43 | 44 | # System Files 45 | .DS_Store 46 | Thumbs.db 47 | 48 | #angular cache 49 | /.angular/ -------------------------------------------------------------------------------- /client/.prettierignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KyleMcMaster/payroll-processor/15f0ed9135ef402678407c5391284be909499932/client/.prettierignore -------------------------------------------------------------------------------- /client/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "semi": true, 5 | "bracketSpacing": true, 6 | "htmlWhitespaceSensitivity": "strict" 7 | } 8 | -------------------------------------------------------------------------------- /client/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "shell", 6 | "command": "npm", 7 | "args": ["install"], 8 | "problemMatcher": [], 9 | "label": "Angular: Install Packages", 10 | "detail": "Install npm project packages for Angular client" 11 | }, 12 | { 13 | "type": "npm", 14 | "script": "start", 15 | "dependsOn": ["Angular: Install Packages"], 16 | "problemMatcher": [], 17 | "label": "Angular: Serve", 18 | "detail": "Serves the Angular client on http://localhost:4201" 19 | }, 20 | { 21 | "type": "npm", 22 | "script": "build", 23 | "dependsOn": ["Angular: Install Packages"], 24 | "problemMatcher": [], 25 | "label": "Angular: Build", 26 | "detail": "Builds the Angular client for production" 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # PayrollProcessorClient 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 8.3.23. 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Running end-to-end tests 22 | 23 | Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/). 24 | 25 | ## Further help 26 | 27 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md). 28 | -------------------------------------------------------------------------------- /client/e2e/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Protractor configuration file, see link for more information 3 | // https://github.com/angular/protractor/blob/master/lib/config.ts 4 | 5 | const { SpecReporter } = require('jasmine-spec-reporter'); 6 | 7 | /** 8 | * @type { import("protractor").Config } 9 | */ 10 | exports.config = { 11 | allScriptsTimeout: 11000, 12 | specs: [ 13 | './src/**/*.e2e-spec.ts' 14 | ], 15 | capabilities: { 16 | browserName: 'chrome' 17 | }, 18 | directConnect: true, 19 | baseUrl: 'http://localhost:4200/', 20 | framework: 'jasmine', 21 | jasmineNodeOpts: { 22 | showColors: true, 23 | defaultTimeoutInterval: 30000, 24 | print: function() {} 25 | }, 26 | onPrepare() { 27 | require('ts-node').register({ 28 | project: require('path').join(__dirname, './tsconfig.json') 29 | }); 30 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 31 | } 32 | }; -------------------------------------------------------------------------------- /client/e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppPage } from './app.po'; 2 | import { browser, logging } from 'protractor'; 3 | 4 | describe('workspace-project App', () => { 5 | let page: AppPage; 6 | 7 | beforeEach(() => { 8 | page = new AppPage(); 9 | }); 10 | 11 | it('should display welcome message', () => { 12 | page.navigateTo(); 13 | expect(page.getTitleText()).toEqual('payroll-processor-client app is running!'); 14 | }); 15 | 16 | afterEach(async () => { 17 | // Assert that there are no errors emitted from the browser 18 | const logs = await browser.manage().logs().get(logging.Type.BROWSER); 19 | expect(logs).not.toContain(jasmine.objectContaining({ 20 | level: logging.Level.SEVERE, 21 | } as logging.Entry)); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /client/e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo() { 5 | return browser.get(browser.baseUrl) as Promise; 6 | } 7 | 8 | getTitleText() { 9 | return element(by.css('app-root .content span')).getText() as Promise; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /client/e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/e2e", 5 | "module": "commonjs", 6 | "target": "es2018", 7 | "types": [ 8 | "jasmine", 9 | "jasminewd2", 10 | "node" 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /client/import-sorter.json: -------------------------------------------------------------------------------- 1 | { 2 | "groupRules": [ 3 | { 4 | "regex": "^@angular|rxjs", 5 | "level": 2 6 | }, 7 | { 8 | "regex": "^@datorama|@fortawesome|@ng-bootstrap|ngx-toastr|ngx-spinner", 9 | "level": 5 10 | }, 11 | { 12 | "regex": "^[@]", 13 | "level": 10 14 | }, 15 | { 16 | "regex": "^../", 17 | "level": 18 18 | }, 19 | { 20 | "regex": "^./", 21 | "level": 19 22 | } 23 | ], 24 | "maxLineLength": 120, 25 | "maxBindingNamesPerLine": 8, 26 | "maxDefaultAndBindingNamesPerLine": 8, 27 | "maxNamesPerWrappedLine": 4, 28 | "autoFormat": "onSave", 29 | "exclude": ["node_modules"] 30 | } 31 | -------------------------------------------------------------------------------- /client/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, './coverage/payroll-processor-client'), 20 | reports: ['html', 'lcovonly', 'text-summary'], 21 | fixWebpackSourcePaths: true 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false, 30 | restartOnFileChange: true 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payroll-processor-client", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "npm run ng serve -- --port 4201 -c local", 7 | "build": "ng build -c production", 8 | "test": "ng test", 9 | "lint": "ng lint", 10 | "e2e": "ng e2e" 11 | }, 12 | "private": true, 13 | "dependencies": { 14 | "@angular/animations": "15.1.4", 15 | "@angular/cdk": "15.1.4", 16 | "@angular/common": "15.1.4", 17 | "@angular/compiler": "15.1.4", 18 | "@angular/core": "15.1.4", 19 | "@angular/forms": "15.1.4", 20 | "@angular/localize": "15.1.4", 21 | "@angular/platform-browser": "15.1.4", 22 | "@angular/platform-browser-dynamic": "15.1.4", 23 | "@angular/router": "15.1.4", 24 | "@datorama/akita": "8.0.1", 25 | "@fortawesome/angular-fontawesome": "0.10.1", 26 | "@fortawesome/fontawesome-svg-core": "1.2.36", 27 | "@fortawesome/free-solid-svg-icons": "5.15.4", 28 | "@microsoft/signalr": "6.0.1", 29 | "@ng-bootstrap/ng-bootstrap": "14.0.1", 30 | "bootstrap": "5.2.3", 31 | "hammerjs": "2.0.8", 32 | "ngx-spinner": "15.0.1", 33 | "ngx-toastr": "16.0.2", 34 | "rxjs": "7.8.0", 35 | "tslib": "^2.3.0", 36 | "zone.js": "~0.12.0" 37 | }, 38 | "devDependencies": { 39 | "@angular-devkit/build-angular": "15.2.4", 40 | "@angular/cli": "15.1.5", 41 | "@angular/compiler-cli": "15.1.4", 42 | "@angular/language-service": "15.1.4", 43 | "@types/jasmine": "4.3.1", 44 | "@types/jasminewd2": "2.0.10", 45 | "@types/node": "17.0.14", 46 | "codelyzer": "^6.0.2", 47 | "jasmine-core": "4.5.0", 48 | "jasmine-spec-reporter": "7.0.0", 49 | "karma": "6.4.0", 50 | "karma-chrome-launcher": "3.1.0", 51 | "karma-coverage-istanbul-reporter": "3.0.3", 52 | "karma-jasmine": "~5.1.0", 53 | "karma-jasmine-html-reporter": "2.0.0", 54 | "prettier": "2.8.4", 55 | "protractor": "7.0.0", 56 | "ts-node": "10.9.0", 57 | "tslint": "6.1.3", 58 | "tslint-config-prettier": "1.18.0", 59 | "typescript": "4.8.4" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /client/src/app/admin/admin.component.scss: -------------------------------------------------------------------------------- 1 | @import '../shared/styles/mixins'; 2 | 3 | fa-icon { 4 | @include var(color, light); 5 | } 6 | -------------------------------------------------------------------------------- /client/src/app/admin/admin.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; 3 | 4 | import { faSyncAlt } from '@fortawesome/free-solid-svg-icons'; 5 | 6 | import { ResourcesQuery } from './state/resources.query'; 7 | import { ResourcesService } from './state/resources.service'; 8 | 9 | @Component({ 10 | selector: 'app-admin', 11 | templateUrl: './admin.component.html', 12 | styleUrls: ['./admin.component.scss'], 13 | changeDetection: ChangeDetectionStrategy.OnPush, 14 | }) 15 | export class AdminComponent { 16 | readonly faSyncAlt = faSyncAlt; 17 | readonly isLoading = this.query.selectLoading(); 18 | readonly stats = this.query.stats; 19 | 20 | readonly form = new UntypedFormGroup({ 21 | totalEmployees: new UntypedFormControl(1, { validators: [Validators.min(1)] }), 22 | maxPayrolls: new UntypedFormControl(1, { validators: [Validators.min(1)] }), 23 | }); 24 | 25 | constructor( 26 | private readonly query: ResourcesQuery, 27 | private readonly service: ResourcesService, 28 | ) { 29 | this.service.getStats(); 30 | } 31 | 32 | onRefreshStats() { 33 | this.service.getStats(); 34 | } 35 | 36 | onCreate() { 37 | this.service.create( 38 | this.form.value.totalEmployees, 39 | this.form.value.maxPayrolls, 40 | ); 41 | } 42 | 43 | onReset() { 44 | this.service.reset(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /client/src/app/admin/admin.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | import { ReactiveFormsModule } from '@angular/forms'; 4 | import { RouterModule, Routes } from '@angular/router'; 5 | 6 | import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; 7 | import { NgxSpinnerModule } from 'ngx-spinner'; 8 | 9 | import { AdminComponent } from './admin.component'; 10 | 11 | const routes: Routes = [{ path: '', component: AdminComponent }]; 12 | 13 | @NgModule({ 14 | declarations: [AdminComponent], 15 | imports: [ 16 | NgxSpinnerModule, 17 | CommonModule, 18 | RouterModule.forChild(routes), 19 | FontAwesomeModule, 20 | ReactiveFormsModule, 21 | ], 22 | }) 23 | export class AdminModule {} 24 | -------------------------------------------------------------------------------- /client/src/app/admin/state/resources.client.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient, HttpParams } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { EnvService } from '@shared/env.service'; 6 | 7 | interface CreateDataRequest { 8 | employeesCount: number; 9 | payrollsMaxCount: number; 10 | } 11 | 12 | interface CreateDataResponse { 13 | totalEmployees: number; 14 | totalPayrolls: number; 15 | totalMilliseconds: number; 16 | } 17 | 18 | interface ResourceStatsResponse { 19 | totalEmployees: number; 20 | totalPayrolls: number; 21 | } 22 | 23 | @Injectable({ providedIn: 'root' }) 24 | export class ResourcesClient { 25 | constructor( 26 | private readonly http: HttpClient, 27 | private readonly env: EnvService, 28 | ) {} 29 | 30 | getStats(): Observable { 31 | return this.http.get( 32 | `${this.env.apiRootUrl}/resources/stats`, 33 | ); 34 | } 35 | 36 | createResources(): Observable { 37 | return this.http.post(`${this.env.functionsRootUrl}/resources`, null); 38 | } 39 | 40 | createData(request: CreateDataRequest): Observable { 41 | const params = new HttpParams({ 42 | fromObject: { 43 | employeesCount: request.employeesCount.toString(), 44 | payrollsMaxCount: request.payrollsMaxCount.toString(), 45 | }, 46 | }); 47 | 48 | return this.http.post( 49 | `${this.env.functionsRootUrl}/resources/data`, 50 | null, 51 | { params }, 52 | ); 53 | } 54 | 55 | resetResources(): Observable { 56 | return this.http.delete(`${this.env.functionsRootUrl}/resources`); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /client/src/app/admin/state/resources.query.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { map } from 'rxjs/operators'; 3 | 4 | import { Query } from '@datorama/akita'; 5 | import { NgxSpinnerService } from 'ngx-spinner'; 6 | 7 | import { ResourcesState, ResourcesStore } from './resources.store'; 8 | 9 | @Injectable({ providedIn: 'root' }) 10 | export class ResourcesQuery extends Query { 11 | readonly stats = this.select().pipe(map(({ stats }) => stats)); 12 | 13 | constructor( 14 | protected store: ResourcesStore, 15 | private spinner: NgxSpinnerService, 16 | ) { 17 | super(store); 18 | 19 | this.selectLoading().subscribe({ 20 | next: (isLoading) => 21 | isLoading 22 | ? this.spinner.show('resources') 23 | : this.spinner.hide('resources'), 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /client/src/app/admin/state/resources.store.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { Store, StoreConfig } from '@datorama/akita'; 4 | 5 | export interface ResourcesState { 6 | stats: { 7 | totalPayrolls: number; 8 | totalEmployees: number; 9 | }; 10 | } 11 | 12 | export function createInitialState(): ResourcesState { 13 | return { 14 | stats: { 15 | totalEmployees: 0, 16 | totalPayrolls: 0, 17 | }, 18 | }; 19 | } 20 | 21 | @Injectable({ providedIn: 'root' }) 22 | @StoreConfig({ name: 'resources' }) 23 | export class ResourcesStore extends Store { 24 | constructor() { 25 | super(createInitialState()); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /client/src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { 3 | RouterModule, 4 | Routes, 5 | } from '@angular/router'; 6 | 7 | const routes: Routes = [ 8 | { 9 | path: 'employees', 10 | loadChildren: () => 11 | import('./employee/employee.module').then((m) => m.EmployeeModule), 12 | }, 13 | { 14 | path: 'payrolls', 15 | loadChildren: () => 16 | import('./payroll/payroll.module').then((m) => m.PayrollModule), 17 | }, 18 | { 19 | path: 'admin', 20 | loadChildren: () => 21 | import('./admin/admin.module').then((m) => m.AdminModule), 22 | }, 23 | { path: '', pathMatch: 'full', redirectTo: 'employees' }, 24 | ]; 25 | 26 | @NgModule({ 27 | imports: [RouterModule.forRoot(routes)], 28 | exports: [RouterModule], 29 | }) 30 | export class AppRoutingModule {} 31 | -------------------------------------------------------------------------------- /client/src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 13 | 14 |
15 |
16 | -------------------------------------------------------------------------------- /client/src/app/app.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KyleMcMaster/payroll-processor/15f0ed9135ef402678407c5391284be909499932/client/src/app/app.component.scss -------------------------------------------------------------------------------- /client/src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, waitForAsync } from '@angular/core/testing'; 2 | import { RouterTestingModule } from '@angular/router/testing'; 3 | import { AppComponent } from './app.component'; 4 | 5 | describe('AppComponent', () => { 6 | beforeEach(waitForAsync(() => { 7 | TestBed.configureTestingModule({ 8 | imports: [RouterTestingModule], 9 | declarations: [AppComponent], 10 | }).compileComponents(); 11 | })); 12 | 13 | it('should create the app', () => { 14 | const fixture = TestBed.createComponent(AppComponent); 15 | const app = fixture.debugElement.componentInstance; 16 | expect(app).toBeTruthy(); 17 | }); 18 | 19 | it(`should have as title 'payroll-processor-client'`, () => { 20 | const fixture = TestBed.createComponent(AppComponent); 21 | const app = fixture.debugElement.componentInstance; 22 | expect(app.title).toEqual('payroll-processor-client'); 23 | }); 24 | 25 | it('should render title', () => { 26 | const fixture = TestBed.createComponent(AppComponent); 27 | fixture.detectChanges(); 28 | const compiled = fixture.debugElement.nativeElement; 29 | expect(compiled.querySelector('.content span').textContent).toContain( 30 | 'payroll-processor-client app is running!', 31 | ); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /client/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ActivatedRoute } from '@angular/router'; 3 | 4 | import { NotificationService } from '@shared/notification.service'; 5 | 6 | @Component({ 7 | selector: 'app-root', 8 | templateUrl: './app.component.html', 9 | styleUrls: ['./app.component.scss'], 10 | }) 11 | export class AppComponent implements OnInit { 12 | readonly title = 'payroll-processor'; 13 | 14 | readonly fragment = this.route.fragment; 15 | 16 | readonly links = [ 17 | { path: 'employees', title: 'Employees' }, 18 | { path: 'payrolls', title: 'Payrolls' }, 19 | { path: 'admin', title: 'Admin' }, 20 | ]; 21 | 22 | constructor( 23 | private readonly notificationService: NotificationService, 24 | private readonly route: ActivatedRoute, 25 | ) {} 26 | 27 | ngOnInit() { 28 | this.notificationService.startConnection(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /client/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { HttpClientModule } from '@angular/common/http'; 2 | import { NgModule } from '@angular/core'; 3 | import { BrowserModule } from '@angular/platform-browser'; 4 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 5 | 6 | import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; 7 | import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; 8 | import { ToastrModule } from 'ngx-toastr'; 9 | 10 | import { SharedModule } from '@shared/shared.module'; 11 | 12 | import { AppRoutingModule } from './app-routing.module'; 13 | import { AppComponent } from './app.component'; 14 | import { CoreModule } from './core/core.module'; 15 | 16 | const NG_MODULES = [BrowserAnimationsModule, BrowserModule, HttpClientModule]; 17 | 18 | const THIRD_PARTY_MODULES = [ 19 | FontAwesomeModule, 20 | NgbModule, 21 | ToastrModule.forRoot({ 22 | timeOut: 10000, 23 | positionClass: 'toast-bottom-right', 24 | preventDuplicates: false, 25 | }), 26 | ]; 27 | 28 | const APP_MODULES = [AppRoutingModule, CoreModule, SharedModule]; 29 | 30 | @NgModule({ 31 | declarations: [AppComponent], 32 | imports: [NG_MODULES, THIRD_PARTY_MODULES, APP_MODULES], 33 | bootstrap: [AppComponent], 34 | }) 35 | export class AppModule {} 36 | -------------------------------------------------------------------------------- /client/src/app/core/core.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule } from '@angular/router'; 3 | 4 | import { NotFoundComponent } from './not-found.component'; 5 | 6 | const routes = [{ path: '**', component: NotFoundComponent }]; 7 | 8 | @NgModule({ 9 | imports: [RouterModule.forChild(routes)], 10 | declarations: [NotFoundComponent], 11 | }) 12 | export class CoreModule {} 13 | -------------------------------------------------------------------------------- /client/src/app/core/not-found.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-not-found', 5 | template: ` 6 |
7 |
8 |
9 |

Could not find the page you were looking for

10 |
11 |
12 |
13 | `, 14 | changeDetection: ChangeDetectionStrategy.OnPush, 15 | }) 16 | export class NotFoundComponent {} 17 | -------------------------------------------------------------------------------- /client/src/app/department/department.model.ts: -------------------------------------------------------------------------------- 1 | export const departments: string[] = [ 2 | 'Building_Services', 3 | 'Human_Resources', 4 | 'IT', 5 | 'Marketing', 6 | 'Sales', 7 | 'Warehouse', 8 | ]; 9 | -------------------------------------------------------------------------------- /client/src/app/employee/employee-create/employee-create.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KyleMcMaster/payroll-processor/15f0ed9135ef402678407c5391284be909499932/client/src/app/employee/employee-create/employee-create.component.scss -------------------------------------------------------------------------------- /client/src/app/employee/employee-create/employee-create.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; 2 | 3 | import { EmployeeCreateComponent } from '@employee/employee-create/employee-create.component'; 4 | 5 | describe('EmployeeCreateComponent', () => { 6 | let component: EmployeeCreateComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(waitForAsync(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [EmployeeCreateComponent], 12 | }).compileComponents(); 13 | })); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(EmployeeCreateComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /client/src/app/employee/employee-create/employee-create.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; 3 | 4 | import { departments } from '@department/department.model'; 5 | import { EmployeeCreate } from '@employee/employee-list/state/employee-list.model'; 6 | import { EmployeeListService } from '@employee/employee-list/state/employee-list.service'; 7 | 8 | @Component({ 9 | selector: 'app-employee-create', 10 | templateUrl: './employee-create.component.html', 11 | styleUrls: ['./employee-create.component.scss'], 12 | }) 13 | export class EmployeeCreateComponent { 14 | readonly departments = departments; 15 | 16 | filterForm = new UntypedFormGroup({ 17 | department: new UntypedFormControl('Choose a department'), 18 | email: new UntypedFormControl(''), 19 | employmentStartedOn: new UntypedFormControl(''), 20 | firstName: new UntypedFormControl(''), 21 | lastName: new UntypedFormControl(''), 22 | phone: new UntypedFormControl(''), 23 | title: new UntypedFormControl(''), 24 | }); 25 | 26 | constructor(private employeeListService: EmployeeListService) {} 27 | 28 | create() { 29 | const employee: EmployeeCreate = { 30 | department: this.filterForm.get('department').value, 31 | email: this.filterForm.get('email').value, 32 | employmentStartedOn: this.filterForm.get('employmentStartedOn').value, 33 | firstName: this.filterForm.get('firstName').value, 34 | lastName: this.filterForm.get('lastName').value, 35 | phone: this.filterForm.get('phone').value, 36 | status: 'Enabled', 37 | title: this.filterForm.get('title').value, 38 | }; 39 | 40 | this.employeeListService.createEmployee(employee); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /client/src/app/employee/employee-detail/employee-detail.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KyleMcMaster/payroll-processor/15f0ed9135ef402678407c5391284be909499932/client/src/app/employee/employee-detail/employee-detail.component.scss -------------------------------------------------------------------------------- /client/src/app/employee/employee-detail/employee-detail.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; 2 | 3 | import { EmployeeDetailComponent } from '@employee/employee-detail/employee-detail.component'; 4 | 5 | describe('EmployeeDetailComponent', () => { 6 | let component: EmployeeDetailComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(waitForAsync(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [EmployeeDetailComponent], 12 | }).compileComponents(); 13 | })); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(EmployeeDetailComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /client/src/app/employee/employee-detail/state/employee-detail.model.ts: -------------------------------------------------------------------------------- 1 | export interface EmployeeDetail { 2 | id: string; 3 | department: Department; 4 | email: string; 5 | employmentStartedOn: string; 6 | firstName: string; 7 | lastName: string; 8 | payrolls: EmployeePayroll[]; 9 | phone: string; 10 | status: Status; 11 | title: string; 12 | version: string; 13 | } 14 | 15 | export type Status = 'Enabled' | 'Disabled'; 16 | 17 | export type Department = 'HR' | 'IT' | 'Sales' | 'Finance' | 'UNKNOWN'; 18 | 19 | export function createInitialState(): EmployeeDetail { 20 | return { 21 | id: '', 22 | department: 'UNKNOWN', 23 | email: '', 24 | employmentStartedOn: '', 25 | firstName: '', 26 | lastName: '', 27 | payrolls: [], 28 | phone: '', 29 | status: 'Enabled', 30 | title: '', 31 | version: '', 32 | }; 33 | } 34 | 35 | export interface EmployeeUpdate { 36 | id: string; 37 | department: Department; 38 | email: string; 39 | employmentStartedOn: string; 40 | firstName: string; 41 | lastName: string; 42 | phone: string; 43 | status: Status; 44 | title: string; 45 | version: string; 46 | } 47 | 48 | export interface EmployeePayroll { 49 | id: string; 50 | checkDate: string; 51 | employeeId: string; 52 | grossPayroll: number; 53 | payrollPeriod: number; 54 | version: string; 55 | } 56 | 57 | export interface EmployeePayrollCreate { 58 | checkDate: string; 59 | employeeId: string; 60 | grossPayroll: number; 61 | } 62 | -------------------------------------------------------------------------------- /client/src/app/employee/employee-detail/state/employee-detail.query.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { Query } from '@datorama/akita'; 4 | 5 | import { EmployeeDetail } from '@employee/employee-detail/state/employee-detail.model'; 6 | import { EmployeeDetailStore } from '@employee/employee-detail/state/employee-detail.store'; 7 | 8 | @Injectable({ providedIn: 'root' }) 9 | export class EmployeeDetailQuery extends Query { 10 | constructor(protected store: EmployeeDetailStore) { 11 | super(store); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /client/src/app/employee/employee-detail/state/employee-detail.store.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { Store, StoreConfig } from '@datorama/akita'; 4 | 5 | import { createInitialState, EmployeeDetail } from '@employee/employee-detail/state/employee-detail.model'; 6 | 7 | @Injectable({ providedIn: 'root' }) 8 | @StoreConfig({ name: 'employee' }) 9 | export class EmployeeDetailStore extends Store { 10 | constructor() { 11 | super(createInitialState()); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /client/src/app/employee/employee-list/employee-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; 2 | 3 | import { EmployeeListComponent } from '@employee/employee-list/employee-list.component'; 4 | 5 | describe('EmployeeListComponent', () => { 6 | let component: EmployeeListComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(waitForAsync(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [EmployeeListComponent], 12 | }).compileComponents(); 13 | })); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(EmployeeListComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /client/src/app/employee/employee-list/employee-list.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | EventEmitter, 4 | Input, 5 | Output, 6 | } from '@angular/core'; 7 | 8 | import { 9 | faSkull, 10 | faSmileBeam, 11 | } from '@fortawesome/free-solid-svg-icons'; 12 | 13 | import { EmployeeListItem } from '@employee/employee-list/state/employee-list.model'; 14 | 15 | @Component({ 16 | selector: 'app-employee-list', 17 | template: ` 18 |
19 |
23 |
24 |
25 | {{ employee.firstName }} {{ employee.lastName }} 26 |
27 |
28 | 29 | 30 | 31 | 32 |
33 |
34 |
35 |
36 | `, 37 | }) 38 | export class EmployeeListComponent { 39 | readonly faSkull = faSkull; 40 | readonly faSmileBeam = faSmileBeam; 41 | 42 | @Input() 43 | employees: EmployeeListItem[]; 44 | 45 | @Output() 46 | selected = new EventEmitter(); 47 | 48 | constructor() {} 49 | } 50 | -------------------------------------------------------------------------------- /client/src/app/employee/employee-list/state/employee-list.model.ts: -------------------------------------------------------------------------------- 1 | export interface EmployeeListItem { 2 | id: string; 3 | department: Department; 4 | firstName: string; 5 | lastName: string; 6 | status: Status; 7 | } 8 | 9 | export type Status = 'Enabled' | 'Disabled'; 10 | 11 | export type Department = 'HR' | 'IT' | 'Sales' | 'Finance' | 'UNKNOWN'; 12 | 13 | export function createInitialState(): EmployeeListItem { 14 | return { 15 | id: '', 16 | department: 'UNKNOWN', 17 | firstName: '', 18 | lastName: '', 19 | status: 'Enabled', 20 | }; 21 | } 22 | 23 | export interface EmployeeCreate { 24 | department: Department; 25 | email: string; 26 | employmentStartedOn: string; 27 | firstName: string; 28 | lastName: string; 29 | phone: string; 30 | status: Status; 31 | title: string; 32 | } 33 | -------------------------------------------------------------------------------- /client/src/app/employee/employee-list/state/employee-list.query.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { QueryEntity } from '@datorama/akita'; 4 | 5 | import { EmployeeListItem } from '@employee/employee-list/state/employee-list.model'; 6 | import { EmployeeListItemState, EmployeeListStore } from '@employee/employee-list/state/employee-list.store'; 7 | 8 | @Injectable({ providedIn: 'root' }) 9 | export class EmployeeListQuery extends QueryEntity< 10 | EmployeeListItemState, 11 | EmployeeListItem 12 | > { 13 | constructor(protected store: EmployeeListStore) { 14 | super(store); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /client/src/app/employee/employee-list/state/employee-list.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { of } from 'rxjs'; 4 | import { catchError } from 'rxjs/operators'; 5 | 6 | import { ToastrService } from 'ngx-toastr'; 7 | 8 | import { EmployeeDetail } from '@employee/employee-detail/state/employee-detail.model'; 9 | import { EmployeeCreate, EmployeeListItem } from '@employee/employee-list/state/employee-list.model'; 10 | import { EmployeeListStore } from '@employee/employee-list/state/employee-list.store'; 11 | import { EnvService } from '@shared/env.service'; 12 | import { ListResponse, mapListResponseToData } from '@shared/list-response'; 13 | 14 | @Injectable({ providedIn: 'root' }) 15 | export class EmployeeListService { 16 | private readonly apiRootUrl: string; 17 | 18 | constructor( 19 | private http: HttpClient, 20 | envService: EnvService, 21 | private store: EmployeeListStore, 22 | private toastr: ToastrService, 23 | ) { 24 | this.apiRootUrl = envService.apiRootUrl; 25 | } 26 | 27 | getEmployees(): void { 28 | this.store.setLoading(true); 29 | this.http 30 | .get>(`${this.apiRootUrl}/Employees`) 31 | .pipe( 32 | catchError((err) => { 33 | this.store.setError({ 34 | message: 'Could not load employees', 35 | }); 36 | return of([]); 37 | }), 38 | mapListResponseToData(), 39 | ) 40 | .subscribe({ 41 | next: (response) => this.store.set(response), 42 | complete: () => this.store.setLoading(false), 43 | }); 44 | } 45 | 46 | createEmployee(employee: EmployeeCreate): void { 47 | this.store.setLoading(true); 48 | this.http 49 | .post(`${this.apiRootUrl}/employees`, employee) 50 | .subscribe({ 51 | error: () => this.toastr.error(`Could not create employee`), 52 | next: (detail) => { 53 | this.toastr.show('Employee sucessfully created!'); 54 | this.store.upsert(detail.id, detail); 55 | }, 56 | complete: () => this.store.setLoading(false), 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /client/src/app/employee/employee-list/state/employee-list.store.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { EntityState, EntityStore, StoreConfig } from '@datorama/akita'; 4 | 5 | import { createInitialState, EmployeeListItem } from '@employee/employee-list/state/employee-list.model'; 6 | 7 | export interface EmployeeListItemState extends EntityState {} 8 | 9 | @Injectable({ providedIn: 'root' }) 10 | @StoreConfig({ name: 'employees' }) 11 | export class EmployeeListStore extends EntityStore { 12 | constructor() { 13 | super(createInitialState()); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/src/app/employee/employee-payroll-create/employee-payroll-create.component.html: -------------------------------------------------------------------------------- 1 |
2 |
Create Payroll
3 |
4 |
5 | 6 |
7 |
8 | 9 |
10 |
11 |
12 |
13 | 18 |
19 |
20 | 21 |
22 |
23 | 24 |
25 |
26 |
27 | -------------------------------------------------------------------------------- /client/src/app/employee/employee-payroll-create/employee-payroll-create.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KyleMcMaster/payroll-processor/15f0ed9135ef402678407c5391284be909499932/client/src/app/employee/employee-payroll-create/employee-payroll-create.component.scss -------------------------------------------------------------------------------- /client/src/app/employee/employee-payroll-create/employee-payroll-create.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; 2 | 3 | import { EmployeePayrollCreateComponent } from './employee-payroll-create.component'; 4 | 5 | describe('EmployeePayrollCreateComponent', () => { 6 | let component: EmployeePayrollCreateComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(waitForAsync(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ EmployeePayrollCreateComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(EmployeePayrollCreateComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /client/src/app/employee/employee-payroll-create/employee-payroll-create.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; 3 | 4 | import { EmployeeDetail, EmployeePayrollCreate } from '@employee/employee-detail/state/employee-detail.model'; 5 | import { EmployeeDetailService } from '@employee/employee-detail/state/employee-detail.service'; 6 | 7 | @Component({ 8 | selector: 'app-employee-payroll-create', 9 | templateUrl: './employee-payroll-create.component.html', 10 | styleUrls: ['./employee-payroll-create.component.scss'], 11 | }) 12 | export class EmployeePayrollCreateComponent { 13 | filterForm = new UntypedFormGroup({ 14 | checkDate: new UntypedFormControl(''), 15 | employeeId: new UntypedFormControl(''), 16 | grossPayroll: new UntypedFormControl(''), 17 | }); 18 | 19 | @Input() 20 | set employee(employee: EmployeeDetail) { 21 | this.filterForm.patchValue({ 22 | employeeId: employee.id, 23 | checkDate: '', 24 | grossPayroll: '', 25 | }); 26 | } 27 | 28 | constructor(private detailService: EmployeeDetailService) {} 29 | 30 | create(): void { 31 | const payroll: EmployeePayrollCreate = { 32 | checkDate: this.filterForm.get('checkDate').value, 33 | employeeId: this.filterForm.get('employeeId').value, 34 | grossPayroll: this.filterForm.get('grossPayroll').value, 35 | }; 36 | 37 | this.detailService.createPayroll(payroll); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /client/src/app/employee/employee-payroll-list/employee-payroll-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; 2 | 3 | import { EmployeePayrollListComponent } from '@employee/employee-payroll-list/employee-payroll-list.component'; 4 | 5 | describe('EmployeePayrollListComponent', () => { 6 | let component: EmployeePayrollListComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(waitForAsync(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [EmployeePayrollListComponent], 12 | }).compileComponents(); 13 | })); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(EmployeePayrollListComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /client/src/app/employee/employee-payroll-list/employee-payroll-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | import { EmployeePayroll } from '@employee/employee-detail/state/employee-detail.model'; 4 | 5 | @Component({ 6 | selector: 'app-employee-payroll-list', 7 | template: `
Recent Payrolls
8 |
    9 |
  • 13 | {{ payroll.payrollPeriod }} 14 | {{ payroll.checkDate | date }} 15 | 16 | {{ payroll.grossPayroll | currency }} 17 | 18 |
  • 19 |
`, 20 | styles: ['li { background-color: var(--base-color);}'], 21 | }) 22 | export class EmployeePayrollListComponent { 23 | @Input() 24 | payrolls: EmployeePayroll[]; 25 | 26 | constructor() {} 27 | } 28 | -------------------------------------------------------------------------------- /client/src/app/employee/employee.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Create Employee 9 |
10 |
11 |
12 |
13 |
14 | 18 |
19 |
20 | 21 | 25 |
26 |
27 |
28 | -------------------------------------------------------------------------------- /client/src/app/employee/employee.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KyleMcMaster/payroll-processor/15f0ed9135ef402678407c5391284be909499932/client/src/app/employee/employee.component.scss -------------------------------------------------------------------------------- /client/src/app/employee/employee.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; 2 | 3 | import { EmployeeComponent } from '@employee/employee.component'; 4 | 5 | describe('EmployeeComponent', () => { 6 | let component: EmployeeComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(waitForAsync(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [EmployeeComponent], 12 | }).compileComponents(); 13 | })); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(EmployeeComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /client/src/app/employee/employee.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { EmployeeDetailQuery } from '@employee/employee-detail/state/employee-detail.query'; 3 | import { EmployeeDetailService } from '@employee/employee-detail/state/employee-detail.service'; 4 | import { EmployeeListItem } from '@employee/employee-list/state/employee-list.model'; 5 | import { EmployeeListQuery } from '@employee/employee-list/state/employee-list.query'; 6 | import { EmployeeListService } from '@employee/employee-list/state/employee-list.service'; 7 | import { faPlus } from '@fortawesome/free-solid-svg-icons'; 8 | 9 | @Component({ 10 | selector: 'app-employee', 11 | templateUrl: './employee.component.html', 12 | styleUrls: ['./employee.component.scss'], 13 | }) 14 | export class EmployeeComponent { 15 | readonly faPlus = faPlus; 16 | readonly employee = this.detailQuery.select(); 17 | readonly employees = this.listQuery.selectAll(); 18 | uiState = ''; 19 | 20 | constructor( 21 | private detailQuery: EmployeeDetailQuery, 22 | private detailService: EmployeeDetailService, 23 | private listService: EmployeeListService, 24 | private listQuery: EmployeeListQuery, 25 | ) { 26 | this.listService.getEmployees(); 27 | } 28 | 29 | onCreate() { 30 | this.uiState = 'create'; 31 | } 32 | 33 | onSelectEmployee(employee: EmployeeListItem) { 34 | this.detailService.getEmployee(employee.id); 35 | this.uiState = 'detail'; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /client/src/app/employee/employee.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | import { ReactiveFormsModule } from '@angular/forms'; 4 | import { RouterModule, Routes } from '@angular/router'; 5 | 6 | import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; 7 | import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; 8 | 9 | import { SharedModule } from '@shared/shared.module'; 10 | 11 | import { EmployeeCreateComponent } from './employee-create/employee-create.component'; 12 | import { EmployeeDetailComponent } from './employee-detail/employee-detail.component'; 13 | import { EmployeeListComponent } from './employee-list/employee-list.component'; 14 | import { EmployeePayrollCreateComponent } from './employee-payroll-create/employee-payroll-create.component'; 15 | import { EmployeePayrollListComponent } from './employee-payroll-list/employee-payroll-list.component'; 16 | import { EmployeeComponent } from './employee.component'; 17 | 18 | const routes: Routes = [{ path: '', component: EmployeeComponent }]; 19 | 20 | @NgModule({ 21 | declarations: [ 22 | EmployeeComponent, 23 | EmployeeCreateComponent, 24 | EmployeeDetailComponent, 25 | EmployeeListComponent, 26 | EmployeePayrollCreateComponent, 27 | EmployeePayrollListComponent, 28 | ], 29 | imports: [ 30 | RouterModule.forChild(routes), 31 | NgbModule, 32 | FontAwesomeModule, 33 | CommonModule, 34 | ReactiveFormsModule, 35 | SharedModule, 36 | ], 37 | }) 38 | export class EmployeeModule {} 39 | -------------------------------------------------------------------------------- /client/src/app/payroll/payroll-list/payroll-list.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
NamePayroll PeriodCheck DateAmount
13 | {{ payroll.employeeFirstName }} {{ payroll.employeeLastName }} 14 | {{ payroll.payrollPeriod }}{{ payroll.checkDate | date }}{{ payroll.grossPayroll | currency }}
29 | -------------------------------------------------------------------------------- /client/src/app/payroll/payroll-list/payroll-list.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../shared/styles/mixins'; 2 | 3 | tbody { 4 | @include var(color, light); 5 | } 6 | 7 | thead { 8 | @include var(color, light); 9 | } 10 | 11 | tbody :hover { 12 | @include var(background-color, light); 13 | @include var(color, app-background); 14 | } 15 | 16 | table { 17 | @include var(background-color, app-background); 18 | } 19 | -------------------------------------------------------------------------------- /client/src/app/payroll/payroll-list/payroll-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; 2 | 3 | import { PayrollListComponent } from '@payroll/payroll-list/payroll-list.component'; 4 | 5 | describe('PayrollListComponent', () => { 6 | let component: PayrollListComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(waitForAsync(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [PayrollListComponent], 12 | }).compileComponents(); 13 | })); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(PayrollListComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /client/src/app/payroll/payroll-list/payroll-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | import { PayrollListItem } from '@payroll/payroll-list/state/payroll-list.model'; 4 | 5 | @Component({ 6 | selector: 'app-payroll-list', 7 | templateUrl: './payroll-list.component.html', 8 | styleUrls: ['./payroll-list.component.scss'], 9 | }) 10 | export class PayrollListComponent { 11 | @Input() 12 | payrolls: PayrollListItem[]; 13 | 14 | constructor() {} 15 | } 16 | -------------------------------------------------------------------------------- /client/src/app/payroll/payroll-list/state/payroll-list.model.ts: -------------------------------------------------------------------------------- 1 | export interface PayrollListItem { 2 | id: string; 3 | checkDate: string; 4 | employeeDepartment: Department; 5 | employeeFirstName: string; 6 | employeeLastName: string; 7 | grossPayroll: number; 8 | payrollPeriod: string; 9 | version: string; 10 | } 11 | 12 | export type Department = 13 | | 'Building Services' 14 | | 'Human Resources' 15 | | 'IT' 16 | | 'Marketing' 17 | | 'Sales' 18 | | 'Warehouse' 19 | | 'UNKNOWN'; 20 | 21 | export function createInitialState(): PayrollListItem { 22 | return { 23 | id: '', 24 | checkDate: '', 25 | employeeDepartment: 'UNKNOWN', 26 | employeeFirstName: '', 27 | employeeLastName: '', 28 | grossPayroll: 0.0, 29 | payrollPeriod: '', 30 | version: '', 31 | }; 32 | } 33 | 34 | export interface PayrollCreate { 35 | checkDate: string; 36 | employeeId: string; 37 | grossPayroll: number; 38 | payrollPeriod: string; 39 | } 40 | -------------------------------------------------------------------------------- /client/src/app/payroll/payroll-list/state/payroll-list.query.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { QueryEntity } from '@datorama/akita'; 4 | 5 | import { PayrollListState, PayrollListStore } from '@payroll/payroll-list/state/payroll-list.store'; 6 | 7 | @Injectable({ providedIn: 'root' }) 8 | export class PayrollListQuery extends QueryEntity { 9 | constructor(protected store: PayrollListStore) { 10 | super(store); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /client/src/app/payroll/payroll-list/state/payroll-list.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient, HttpParams } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | 4 | import { ToastrService } from 'ngx-toastr'; 5 | 6 | import { PayrollListItem } from '@payroll/payroll-list/state/payroll-list.model'; 7 | import { PayrollListStore } from '@payroll/payroll-list/state/payroll-list.store'; 8 | import { EnvService } from '@shared/env.service'; 9 | import { ListResponse, mapListResponseToData } from '@shared/list-response'; 10 | 11 | @Injectable({ providedIn: 'root' }) 12 | export class PayrollListService { 13 | readonly apiRootUrl: string; 14 | 15 | constructor( 16 | envService: EnvService, 17 | private readonly http: HttpClient, 18 | private readonly store: PayrollListStore, 19 | private toastr: ToastrService, 20 | ) { 21 | this.apiRootUrl = envService.apiRootUrl; 22 | } 23 | 24 | getPayrolls(department: string): void { 25 | this.store.setLoading(true); 26 | 27 | let params = new HttpParams(); 28 | params = params.append('Department', department); 29 | params = params.append('Count', '10'); 30 | 31 | this.http 32 | .get>( 33 | `${this.apiRootUrl}/departments/payrolls`, 34 | { 35 | params, 36 | }, 37 | ) 38 | .pipe(mapListResponseToData()) 39 | .subscribe({ 40 | error: () => this.toastr.error('Could not load payrolls'), 41 | next: (response) => this.store.set(response), 42 | complete: () => this.store.setLoading(false), 43 | }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /client/src/app/payroll/payroll-list/state/payroll-list.store.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { EntityState, EntityStore, StoreConfig } from '@datorama/akita'; 4 | 5 | import { createInitialState, PayrollListItem } from '@payroll/payroll-list/state/payroll-list.model'; 6 | 7 | export interface PayrollListState extends EntityState {} 8 | 9 | @Injectable({ providedIn: 'root' }) 10 | @StoreConfig({ name: 'payrolls' }) 11 | export class PayrollListStore extends EntityStore { 12 | constructor() { 13 | super(createInitialState()); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/src/app/payroll/payroll.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
8 | Departments 9 |
10 |
11 |
16 |
22 | {{ department | unslugify }} 23 |
24 |
25 |
26 |
27 |
28 |
29 | 30 |
31 |
32 |
33 |
34 |
35 | -------------------------------------------------------------------------------- /client/src/app/payroll/payroll.component.scss: -------------------------------------------------------------------------------- 1 | .table-margin { 2 | padding-left: 314px; 3 | padding-right: 314px; 4 | margin-left: auto; 5 | margin-right: auto; 6 | } 7 | -------------------------------------------------------------------------------- /client/src/app/payroll/payroll.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; 2 | 3 | import { PayrollComponent } from '@payroll/payroll.component'; 4 | 5 | describe('PayrollComponent', () => { 6 | let component: PayrollComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(waitForAsync(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [PayrollComponent], 12 | }).compileComponents(); 13 | })); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(PayrollComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /client/src/app/payroll/payroll.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { departments } from '@department/department.model'; 3 | import { faLock, faUnlock } from '@fortawesome/free-solid-svg-icons'; 4 | import { PayrollListQuery } from '@payroll/payroll-list/state/payroll-list.query'; 5 | import { PayrollListService } from '@payroll/payroll-list/state/payroll-list.service'; 6 | 7 | @Component({ 8 | selector: 'app-payroll', 9 | templateUrl: './payroll.component.html', 10 | styleUrls: ['./payroll.component.scss'], 11 | }) 12 | export class PayrollComponent { 13 | readonly faLock = faLock; 14 | readonly faUnlock = faUnlock; 15 | readonly payrolls = this.query.selectAll(); 16 | selectedDepartment = 'Building_Services'; 17 | 18 | readonly departments = departments; 19 | 20 | constructor( 21 | private query: PayrollListQuery, 22 | private service: PayrollListService, 23 | ) { 24 | this.service.getPayrolls(this.selectedDepartment); 25 | } 26 | 27 | onSelectDepartment(department: string) { 28 | this.service.getPayrolls(department); 29 | this.selectedDepartment = department; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/src/app/payroll/payroll.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | import { RouterModule, Routes } from '@angular/router'; 4 | 5 | import { SharedModule } from '@shared/shared.module'; 6 | 7 | import { PayrollListComponent } from './payroll-list/payroll-list.component'; 8 | import { PayrollComponent } from './payroll.component'; 9 | 10 | const routes: Routes = [{ path: '', component: PayrollComponent }]; 11 | 12 | @NgModule({ 13 | declarations: [PayrollComponent, PayrollListComponent], 14 | imports: [RouterModule.forChild(routes), CommonModule, SharedModule], 15 | }) 16 | export class PayrollModule {} 17 | -------------------------------------------------------------------------------- /client/src/app/shared/api-error.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse } from '@angular/common/http'; 2 | 3 | export function mapErrorResponse( 4 | callback: (error: APIError) => void, 5 | ): (response: HttpErrorResponse) => void { 6 | return (response) => callback(response.error as APIError); 7 | } 8 | 9 | export interface APIError { 10 | id: string; 11 | message: string; 12 | } 13 | -------------------------------------------------------------------------------- /client/src/app/shared/clock.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | @Injectable({ 4 | providedIn: 'root', 5 | }) 6 | export class ClockService { 7 | now(): Date { 8 | return new Date(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /client/src/app/shared/env.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { environment } from '../../environments/environment'; 3 | import { Environment } from '../../environments/environment-types'; 4 | 5 | @Injectable({ 6 | providedIn: 'root', 7 | }) 8 | export class EnvService { 9 | get env(): Environment { 10 | return environment; 11 | } 12 | 13 | get functionsRootUrl(): string { 14 | return `${this.env.functionsDomain}/api`; 15 | } 16 | 17 | get apiRootUrl(): string { 18 | return `${this.env.apiDomain}/api/v1`; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /client/src/app/shared/list-response.ts: -------------------------------------------------------------------------------- 1 | import { map } from 'rxjs/operators'; 2 | 3 | /** 4 | * Represents the shape of an API response containing a collection of items 5 | */ 6 | export interface ListResponse { 7 | readonly data: T[]; 8 | } 9 | 10 | /** 11 | * Maps API list responses to the collection of objects they contain 12 | */ 13 | export function mapListResponseToData() { 14 | return map, T[]>(({ data }) => data); 15 | } 16 | -------------------------------------------------------------------------------- /client/src/app/shared/notification.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import * as signalR from '@microsoft/signalr'; 4 | import { EnvService } from '@shared/env.service'; 5 | 6 | @Injectable({ 7 | providedIn: 'root', 8 | }) 9 | export class NotificationService { 10 | private hubConnection: signalR.HubConnection; 11 | private readonly apiUrl: string; 12 | 13 | constructor(envService: EnvService) { 14 | this.apiUrl = envService.env.apiDomain; 15 | } 16 | 17 | startConnection() { 18 | this.hubConnection = new signalR.HubConnectionBuilder() 19 | .withUrl(`${this.apiUrl}/hub/notifications`) 20 | .build(); 21 | 22 | this.hubConnection 23 | .start() 24 | .then(() => console.log('Connection started')) 25 | .catch((err) => console.log('Error while starting connection: ' + err)); 26 | 27 | this.hubConnection.on('received', ({ source, message }) => { 28 | console.log(source, JSON.parse(message)); 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/src/app/shared/shared.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { UnslugifyPipe } from '@shared/unslugify.pipe'; 4 | 5 | @NgModule({ 6 | declarations: [UnslugifyPipe], 7 | exports: [UnslugifyPipe], 8 | }) 9 | export class SharedModule {} 10 | -------------------------------------------------------------------------------- /client/src/app/shared/styles/_mixins.scss: -------------------------------------------------------------------------------- 1 | @import './variables'; 2 | // CSS Var mixin to provide fallbacks automatically 3 | // https://vgpena.github.io/winning-with-css-variables/ 4 | @mixin var($property, $varName) { 5 | #{$property}: map-get($variables, $varName); 6 | #{$property}: var(--#{$varName}, map-get($variables, $varName)); 7 | } 8 | -------------------------------------------------------------------------------- /client/src/app/shared/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | $app-background: #000; 2 | $info: #217dbb; 3 | $light: #fff; 4 | $success: #008966; 5 | $warning: #f39c12; 6 | 7 | $variables: ( 8 | app-background: $app-background, 9 | info: $info, 10 | light: $light, 11 | success: $success, 12 | warning: $warning, 13 | ); 14 | -------------------------------------------------------------------------------- /client/src/app/shared/unslugify.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | @Pipe({ name: 'unslugify' }) 4 | export class UnslugifyPipe implements PipeTransform { 5 | transform(value?: string): string { 6 | return value?.replace('_', ' ') ?? ''; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /client/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KyleMcMaster/payroll-processor/15f0ed9135ef402678407c5391284be909499932/client/src/assets/.gitkeep -------------------------------------------------------------------------------- /client/src/environments/environment-types.ts: -------------------------------------------------------------------------------- 1 | export interface Environment { 2 | production: boolean; 3 | functionsDomain: string; 4 | apiDomain: string; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/environments/environment.dev.ts: -------------------------------------------------------------------------------- 1 | import { Environment } from './environment-types'; 2 | 3 | export const environment: Environment = { 4 | production: false, 5 | functionsDomain: '', 6 | apiDomain: '', 7 | }; 8 | -------------------------------------------------------------------------------- /client/src/environments/environment.local.sample.ts: -------------------------------------------------------------------------------- 1 | import { Environment } from './environment-types'; 2 | 3 | export const environment: Environment = { 4 | production: false, 5 | functionsDomain: 'http://localhost:7071', 6 | apiDomain: 'http://localhost:5000', 7 | }; 8 | -------------------------------------------------------------------------------- /client/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | import { Environment } from './environment-types'; 2 | 3 | export const environment: Environment = { 4 | production: true, 5 | functionsDomain: '', 6 | apiDomain: '', 7 | }; 8 | -------------------------------------------------------------------------------- /client/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | import { Environment } from './environment-types'; 2 | 3 | export const environment: Environment = { 4 | production: false, 5 | // This should come from the launch.json for the API 6 | functionsDomain: '', 7 | apiDomain: '', 8 | }; 9 | -------------------------------------------------------------------------------- /client/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KyleMcMaster/payroll-processor/15f0ed9135ef402678407c5391284be909499932/client/src/favicon.ico -------------------------------------------------------------------------------- /client/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PayrollProcessorClient 6 | 7 | 8 | 9 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /client/src/main.ts: -------------------------------------------------------------------------------- 1 | import 'hammerjs'; 2 | 3 | import { enableProdMode } from '@angular/core'; 4 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 5 | 6 | import { AppModule } from './app/app.module'; 7 | import { environment } from './environments/environment'; 8 | 9 | if (environment.production) { 10 | enableProdMode(); 11 | } 12 | 13 | platformBrowserDynamic() 14 | .bootstrapModule(AppModule) 15 | .catch((err) => console.error(err)); 16 | -------------------------------------------------------------------------------- /client/src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | // Option A: Include all of Bootstrap 3 | 4 | // Include any default variable overrides here (though functions won't be available) 5 | @import "../node_modules/bootstrap/scss/bootstrap"; 6 | // boostrap 5 7 | @import 'ngx-toastr/toastr-bs5-alert'; 8 | 9 | @import './app/shared/styles/mixins'; 10 | :root { 11 | @each $key, $value in $variables { 12 | --#{$key}: #{$value}; 13 | } 14 | } 15 | 16 | html, 17 | body { 18 | height: 100%; 19 | } 20 | body { 21 | margin: 0; 22 | font-family: Roboto, 'Helvetica Neue', sans-serif; 23 | @include var(background-color, app-background); 24 | } 25 | 26 | .full-height { 27 | height: calc(100vh - 60px); 28 | } 29 | 30 | .app-background { 31 | @include var(background-color, app-background); 32 | @include var(color, light); 33 | } 34 | 35 | .border-light { 36 | @include var(border-color, light); 37 | border: 1px solid; 38 | border-radius: 0.25rem; 39 | } 40 | 41 | .border-success { 42 | @include var(border-color, success); 43 | border: 1px solid; 44 | border-radius: 0.25rem; 45 | } 46 | 47 | .border-info { 48 | @include var(border-color, info); 49 | border: 1px solid; 50 | border-radius: 0.25rem; 51 | } 52 | 53 | .border-warning { 54 | @include var(border-color, warning); 55 | border: 1px solid; 56 | border-radius: 0.25rem; 57 | } 58 | 59 | .text-center { 60 | text-align: center; 61 | } 62 | -------------------------------------------------------------------------------- /client/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | 10 | // First, initialize the Angular testing environment. 11 | getTestBed().initTestEnvironment( 12 | BrowserDynamicTestingModule, 13 | platformBrowserDynamicTesting() 14 | ); 15 | -------------------------------------------------------------------------------- /client/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": ["src/main.ts", "src/polyfills.ts"], 8 | "include": ["src/**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "forceConsistentCasingInFileNames": true, 7 | "sourceMap": true, 8 | "declaration": false, 9 | "downlevelIteration": true, 10 | "experimentalDecorators": true, 11 | "module": "es2020", 12 | "moduleResolution": "node", 13 | "importHelpers": true, 14 | "target": "ES2022", 15 | "typeRoots": [ 16 | "node_modules/@types" 17 | ], 18 | "lib": [ 19 | "es2018", 20 | "dom" 21 | ], 22 | "paths": { 23 | "@department/*": [ 24 | "src/app/department/*" 25 | ], 26 | "@employee/*": [ 27 | "src/app/employee/*" 28 | ], 29 | "@payroll/*": [ 30 | "src/app/payroll/*" 31 | ], 32 | "@shared/*": [ 33 | "src/app/shared/*" 34 | ] 35 | }, 36 | "useDefineForClassFields": false 37 | }, 38 | "angularCompilerOptions": { 39 | "strictTemplates": true, 40 | "strictInjectionParameters": true 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /client/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/spec", 5 | "types": [ 6 | "jasmine", 7 | "node" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts", 12 | "src/polyfills.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.spec.ts", 16 | "src/**/*.d.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /client/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-config-prettier"], 3 | "rules": { 4 | "array-type": false, 5 | "arrow-parens": false, 6 | "deprecation": { 7 | "severity": "warning" 8 | }, 9 | "component-class-suffix": true, 10 | "contextual-lifecycle": true, 11 | "directive-class-suffix": true, 12 | "directive-selector": [true, "attribute", "app", "camelCase"], 13 | "component-selector": [true, "element", "app", "kebab-case"], 14 | "import-blacklist": [true, "rxjs/Rx"], 15 | "interface-name": false, 16 | "max-classes-per-file": false, 17 | "max-line-length": [true, 140], 18 | "member-access": false, 19 | "member-ordering": [ 20 | true, 21 | { 22 | "order": [ 23 | "static-field", 24 | "instance-field", 25 | "static-method", 26 | "instance-method" 27 | ] 28 | } 29 | ], 30 | "no-consecutive-blank-lines": false, 31 | "no-console": [true, "debug", "info", "time", "timeEnd", "trace"], 32 | "no-empty": false, 33 | "no-inferrable-types": [true, "ignore-params"], 34 | "no-non-null-assertion": true, 35 | "no-redundant-jsdoc": true, 36 | "no-switch-case-fall-through": true, 37 | "no-var-requires": false, 38 | "object-literal-key-quotes": [true, "as-needed"], 39 | "object-literal-sort-keys": false, 40 | "ordered-imports": false, 41 | "quotemark": [true, "single"], 42 | "trailing-comma": false, 43 | "no-conflicting-lifecycle": true, 44 | "no-host-metadata-property": true, 45 | "no-input-rename": true, 46 | "no-inputs-metadata-property": true, 47 | "no-output-native": true, 48 | "no-output-on-prefix": true, 49 | "no-output-rename": true, 50 | "no-outputs-metadata-property": true, 51 | "template-banana-in-box": true, 52 | "template-no-negated-async": true, 53 | "use-lifecycle-interface": true, 54 | "use-pipe-transform-interface": true 55 | }, 56 | "rulesDirectory": ["codelyzer"] 57 | } 58 | -------------------------------------------------------------------------------- /docs/ARCHITECTURAL_DECISION_RECORD.md: -------------------------------------------------------------------------------- 1 | # Architectural Decision Record 2 | -------------------------------------------------------------------------------- /docs/ARCHITECTURE.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | ## Diagrams 4 | 5 | Get all employees 6 | 7 | ```mermaid 8 | sequenceDiagram 9 | autonumber 10 | Client ->> GET /api/employees: XHR Request (params) 11 | GET /api/employees ->> Employees (Cosmos): query (params) 12 | Employees (Cosmos) -->> GET /api/employees: employee entities 13 | GET /api/employees -->> Client: XHR Response (employees) 14 | ``` 15 | 16 | Create employee 17 | 18 | ```mermaid 19 | sequenceDiagram 20 | autonumber 21 | Client ->> POST /api/employees: XHR Request (employee) 22 | POST /api/employees ->> Employees (Cosmos): (entity) employee 23 | Employees (Cosmos) -->> POST /api/employees: entity 24 | 25 | par 26 | POST /api/employees -->> Client: XHR Response (employee) 27 | and 28 | POST /api/employees ->> employee updates: message (entity) 29 | Note over POST /api/employees,employee updates: Storage Queue message 30 | employee updates ->> CreatePayrollFromQueue: dequeue 31 | CreatePayrollFromQueue ->> Employees (Cosmos): query (id) 32 | Employees (Cosmos) ->> CreatePayrollFromQueue: employee entity 33 | CreatePayrollFromQueue ->> Departments (Cosmos): (entity) department employee 34 | Departments (Cosmos) ->> CreatePayrollFromQueue: (entity) department employee 35 | CreatePayrollFromQueue ->> POST /api/notifications: department employee 36 | POST /api/notifications ->> Client: SignalR message (department employee) 37 | end 38 | ``` 39 | -------------------------------------------------------------------------------- /vue-client/.env: -------------------------------------------------------------------------------- 1 | /// 2 | VITE_API_BASE_URL="https://localhost:44310" 3 | -------------------------------------------------------------------------------- /vue-client/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require("@rushstack/eslint-patch/modern-module-resolution"); 3 | 4 | module.exports = { 5 | root: true, 6 | extends: [ 7 | "plugin:vue/vue3-essential", 8 | "eslint:recommended", 9 | "@vue/eslint-config-typescript/recommended", 10 | "@vue/eslint-config-prettier", 11 | ], 12 | env: { 13 | "vue/setup-compiler-macros": true, 14 | }, 15 | overrides: [ 16 | { 17 | files: ["cypress/integration/**.spec.{js,ts,jsx,tsx}"], 18 | extends: ["plugin:cypress/recommended"], 19 | }, 20 | ], 21 | }; 22 | -------------------------------------------------------------------------------- /vue-client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | -------------------------------------------------------------------------------- /vue-client/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["johnsoncodehk.volar", "johnsoncodehk.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /vue-client/README.md: -------------------------------------------------------------------------------- 1 | # client 2 | 3 | This template should help get you started developing with Vue 3 in Vite. 4 | 5 | ## Recommended IDE Setup 6 | 7 | [VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.vscode-typescript-vue-plugin). 8 | 9 | ## Type Support for `.vue` Imports in TS 10 | 11 | TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types. 12 | 13 | If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps: 14 | 15 | 1. Disable the built-in TypeScript Extension 16 | 1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette 17 | 2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)` 18 | 2. Reload the VSCode window by running `Developer: Reload Window` from the command palette. 19 | 20 | ## Customize configuration 21 | 22 | See [Vite Configuration Reference](https://vitejs.dev/config/). 23 | 24 | ## Project Setup 25 | 26 | ```sh 27 | npm install 28 | ``` 29 | 30 | ### Compile and Hot-Reload for Development 31 | 32 | ```sh 33 | npm run dev 34 | ``` 35 | 36 | ### Type-Check, Compile and Minify for Production 37 | 38 | ```sh 39 | npm run build 40 | ``` 41 | 42 | ### Run Unit Tests with [Vitest](https://vitest.dev/) 43 | 44 | ```sh 45 | npm run test:unit 46 | ``` 47 | 48 | ### Run End-to-End Tests with [Cypress](https://www.cypress.io/) 49 | 50 | ```sh 51 | npm run build 52 | npm run test:e2e # or `npm run test:e2e:ci` for headless testing 53 | ``` 54 | 55 | ### Lint with [ESLint](https://eslint.org/) 56 | 57 | ```sh 58 | npm run lint 59 | ``` 60 | -------------------------------------------------------------------------------- /vue-client/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:5050" 3 | } 4 | -------------------------------------------------------------------------------- /vue-client/cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /vue-client/cypress/integration/example.spec.ts: -------------------------------------------------------------------------------- 1 | // https://docs.cypress.io/api/introduction/api.html 2 | 3 | describe("My First Test", () => { 4 | it("visits the app root url", () => { 5 | cy.visit("/"); 6 | cy.contains("h1", "You did it!"); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /vue-client/cypress/plugins/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | // *********************************************************** 3 | // This example plugins/index.ts can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | export default ((on, config) => { 16 | // `on` is used to hook into various events Cypress emits 17 | // `config` is the resolved Cypress config 18 | return config; 19 | }) as Cypress.PluginConfig; 20 | -------------------------------------------------------------------------------- /vue-client/cypress/plugins/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.node.json", 3 | "include": ["./**/*"], 4 | "compilerOptions": { 5 | "module": "CommonJS", 6 | "preserveValueImports": false, 7 | "types": ["node", "cypress/types/cypress"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /vue-client/cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /vue-client/cypress/support/index.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import "./commands"; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /vue-client/cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.web.json", 3 | "include": ["./integration/**/*", "./support/**/*"], 4 | "compilerOptions": { 5 | "isolatedModules": false, 6 | "target": "es5", 7 | "lib": ["es5", "dom"], 8 | "types": ["cypress"] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /vue-client/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /vue-client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Payroll Processor 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /vue-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-client", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "vue-tsc --noEmit && vite build", 7 | "preview": "vite preview --port 5050", 8 | "test:unit": "vitest --environment jsdom", 9 | "test:e2e": "start-server-and-test preview http://127.0.0.1:5050/ 'cypress open'", 10 | "test:e2e:ci": "start-server-and-test preview http://127.0.0.1:5050/ 'cypress run'", 11 | "typecheck": "vue-tsc --noEmit", 12 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" 13 | }, 14 | "dependencies": { 15 | "@vueuse/core": "7.6.2", 16 | "pinia": "2.0.11", 17 | "vue": "3.2.31", 18 | "vue-router": "4.0.12" 19 | }, 20 | "devDependencies": { 21 | "@rushstack/eslint-patch": "1.1.0", 22 | "@types/jsdom": "16.2.14", 23 | "@types/node": "16.11.22", 24 | "@vitejs/plugin-vue": "2.1.0", 25 | "@vue/eslint-config-prettier": "7.0.0", 26 | "@vue/eslint-config-typescript": "10.0.0", 27 | "@vue/test-utils": "2.0.0-rc.18", 28 | "@vue/tsconfig": "0.1.3", 29 | "autoprefixer": "^10.4.2", 30 | "cypress": "13.3.3", 31 | "eslint": "8.5.0", 32 | "eslint-plugin-cypress": "2.12.1", 33 | "eslint-plugin-vue": "8.2.0", 34 | "jsdom": "19.0.0", 35 | "postcss": "^8.4.31", 36 | "prettier": "2.5.1", 37 | "start-server-and-test": "2.0.3", 38 | "tailwindcss": "^3.0.23", 39 | "typescript": "4.5.5", 40 | "vite": "2.9.18", 41 | "vitest": "0.2.5", 42 | "vue-tsc": "0.31.1" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /vue-client/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /vue-client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KyleMcMaster/payroll-processor/15f0ed9135ef402678407c5391284be909499932/vue-client/public/favicon.ico -------------------------------------------------------------------------------- /vue-client/settings.ts: -------------------------------------------------------------------------------- 1 | export function getSettings() { 2 | return { 3 | apiBaseUrl: import.meta.env.VITE_API_BASE_URL, 4 | }; 5 | } 6 | -------------------------------------------------------------------------------- /vue-client/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vue-client/src/components/Admin.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 25 | -------------------------------------------------------------------------------- /vue-client/src/components/Header.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 29 | 30 | 35 | 36 | 44 | -------------------------------------------------------------------------------- /vue-client/src/components/ThemeToggle.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 41 | -------------------------------------------------------------------------------- /vue-client/src/components/WelcomeItem.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 87 | -------------------------------------------------------------------------------- /vue-client/src/components/__tests__/Admin.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | } from "vitest"; 6 | 7 | import { mount } from "@vue/test-utils"; 8 | 9 | import Admin from "../Admin.vue"; 10 | 11 | describe("Admin", () => { 12 | it("renders properly", () => { 13 | const wrapper = mount(Admin, { props: { } }); 14 | expect(wrapper.text()).toContain("Resource Stats"); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /vue-client/src/components/icons/IconCommunity.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /vue-client/src/components/icons/IconDocumentation.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /vue-client/src/components/icons/IconEcosystem.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /vue-client/src/components/icons/IconSupport.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /vue-client/src/components/icons/IconTooling.vue: -------------------------------------------------------------------------------- 1 | 2 | 20 | -------------------------------------------------------------------------------- /vue-client/src/main.ts: -------------------------------------------------------------------------------- 1 | import "./assets/base.css"; 2 | 3 | import { createApp } from "vue"; 4 | 5 | import { createPinia } from "pinia"; 6 | 7 | import App from "./App.vue"; 8 | import router from "./router"; 9 | 10 | const app = createApp(App); 11 | 12 | app.use(createPinia()); 13 | app.use(router); 14 | 15 | app.mount("#app"); 16 | -------------------------------------------------------------------------------- /vue-client/src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createRouter, 3 | createWebHistory, 4 | } from "vue-router"; 5 | 6 | const router = createRouter({ 7 | history: createWebHistory(import.meta.env.BASE_URL), 8 | routes: [ 9 | { 10 | path: "/", 11 | name: "home", 12 | // component: HomeView, 13 | redirect: "/admin", 14 | }, 15 | { 16 | path: "/admin", 17 | name: "Admin", 18 | // route level code-splitting 19 | // this generates a separate chunk (About.[hash].js) for this route 20 | // which is lazy-loaded when the route is visited. 21 | component: () => import("@/views/AdminView.vue"), 22 | }, 23 | ], 24 | }); 25 | 26 | export default router; 27 | -------------------------------------------------------------------------------- /vue-client/src/stores/admin.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | 3 | import { getSettings } from "../../settings"; 4 | 5 | const settings = getSettings(); 6 | 7 | export const adminStore = defineStore({ 8 | id: "admin", 9 | state: () => ({ 10 | totalEmployees: 0, 11 | totalPayrolls: 0, 12 | }), 13 | getters: { 14 | employeeCount: (state) => state.totalEmployees, 15 | payrollCount: (state) => state.totalPayrolls, 16 | }, 17 | actions: { 18 | async getData() { 19 | const res = await fetch(`${settings.apiBaseUrl}/api/v1/resources/stats`); 20 | const data = await res.json(); 21 | this.totalEmployees = data.totalEmployees; 22 | this.totalPayrolls = data.totalPayrolls; 23 | }, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /vue-client/src/views/AboutView.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | -------------------------------------------------------------------------------- /vue-client/src/views/AdminView.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 14 | -------------------------------------------------------------------------------- /vue-client/src/views/HomeView.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /vue-client/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"], 3 | theme: { 4 | extend: {}, 5 | }, 6 | plugins: [], 7 | }; 8 | -------------------------------------------------------------------------------- /vue-client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.web.json", 3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], 4 | "exclude": ["src/**/__tests__/*"], 5 | "compilerOptions": { 6 | "baseUrl": ".", 7 | "paths": { 8 | "@/*": ["./src/*"] 9 | } 10 | }, 11 | 12 | "references": [ 13 | { 14 | "path": "./tsconfig.vite-config.json" 15 | }, 16 | { 17 | "path": "./tsconfig.vitest.json" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /vue-client/tsconfig.vite-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.node.json", 3 | "include": ["vite.config.*"], 4 | "compilerOptions": { 5 | "composite": true, 6 | "types": ["node", "vitest"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /vue-client/tsconfig.vitest.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.node.json", 3 | "include": ["src/**/__tests__/*"], 4 | "compilerOptions": { 5 | "composite": true, 6 | "types": ["node", "jsdom"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /vue-client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | fileURLToPath, 3 | URL, 4 | } from "url"; 5 | import { defineConfig } from "vite"; 6 | 7 | import vue from "@vitejs/plugin-vue"; 8 | 9 | // https://vitejs.dev/config/ 10 | export default defineConfig({ 11 | plugins: [vue()], 12 | resolve: { 13 | alias: { 14 | "@": fileURLToPath(new URL("./src", import.meta.url)), 15 | }, 16 | }, 17 | }); 18 | --------------------------------------------------------------------------------