diff --git a/.agents/skills/usecase/SKILL.md b/.agents/skills/usecase/SKILL.md index 73afba7..2cf8d51 100644 --- a/.agents/skills/usecase/SKILL.md +++ b/.agents/skills/usecase/SKILL.md @@ -56,4 +56,6 @@ Find and read an existing, similar feature in the codebase as a reference implem # 3. Implement steps -Use the `steps` skill to implement the steps. \ No newline at end of file +Use the `steps` skill to implement the steps. +- If you cannot find that skill, STOP and inform user +- Don't forget to make commits at logical boundaries. diff --git a/.plans/use-cases/02_view_and_edit_requests.md b/.plans/use-cases/02_view_and_edit_requests.md new file mode 100644 index 0000000..5756880 --- /dev/null +++ b/.plans/use-cases/02_view_and_edit_requests.md @@ -0,0 +1,243 @@ +# View/Edit Purchase Requests Use Case + +## Overview + +When a user clicks on a request ID in the requests grid, they can: +- View already-submitted requests (readonly view, with ability to withdraw if not already approved/rejected) +- Edit draft requests + +## Current State + +The following already exists: +- `GetPurchaseRequestById` - query handler for viewing a single request +- `UpdatePurchaseRequest` - command handler for editing (only works for Draft status) +- `SubmitPurchaseRequest` - command handler for submitting drafts +- `DeletePurchaseRequest` - command handler for deleting drafts +- Requests list page at `/requests` with navigation to `/requests/{id}` + +## Missing Functionality + +**Withdraw Request** - No endpoint/handler exists to withdraw a submitted (Pending) request. + +When a request is withdrawn: +- Status changes from `Pending` back to `Draft` +- `SubmittedAt` is cleared +- Request can be edited again and re-submitted + +## Implementation Plan + +### 1. Domain Layer (ProcureHub) + +#### 1.1 Add Withdraw method to PurchaseRequest model + +Add `Withdraw()` method to `Models/PurchaseRequest.cs` that: +- Validates request is in `Pending` status +- Resets status to `Draft` +- Clears `SubmittedAt` +- Updates `UpdatedAt` + +#### 1.2 Add validation error for withdraw + +Add `CannotWithdrawNonPending` error to `Features/PurchaseRequests/Validation/PurchaseRequestErrors.cs` + +#### 1.3 Create WithdrawPurchaseRequest command handler + +Create `Features/PurchaseRequests/WithdrawPurchaseRequest.cs`: +- Command with `Id` parameter +- Handler that loads request, calls `Withdraw()`, saves changes +- Returns `Result` (success or failure) + +### 2. API Layer (ProcureHub.WebApi) + +#### 2.1 Add Withdraw endpoint + +Add `POST /purchase-requests/{id}/withdraw` endpoint to `Features/PurchaseRequests/Endpoints.cs`: +- Requires `Requester` role +- Calls WithdrawPurchaseRequest handler +- Returns 204 on success + +### 3. UI Layer (ProcureHub.BlazorApp) + +#### 3.1 Create View/Edit Request page + +Create `Components/Pages/Requests/View.razor` at route `/requests/{Id:guid}`: + +**For Draft requests:** +- Display form with editable fields (same as New.razor) +- Show "Save Changes" button +- Show "Submit for Approval" button +- Show "Delete" button + +**For Pending requests:** +- Display readonly view of all fields +- Show "Withdraw Request" button (returns to Draft) +- Show status badge prominently + +**For Approved/Rejected requests:** +- Display readonly view of all fields +- Show status badge prominently +- Show approval/rejection info (date, reviewer) +- No action buttons (view only) + +**Page Structure:** +- Two-column layout (same as New.razor) +- Left column: Request details form or readonly display +- Right column: Actions card (context-sensitive based on status) + +### 4. Testing + +#### 4.1 Add Withdraw endpoint to test theory data + +Update `GetAllPurchaseRequestEndpoints` in `PurchaseRequestTests.cs` to include withdraw endpoint + +#### 4.2 Add validation test for withdraw + +Add `Test_WithdrawPurchaseRequest_validation` method + +#### 4.3 Add withdraw functionality tests + +Add tests for: +- Can withdraw pending request (returns to Draft, SubmittedAt cleared) +- Cannot withdraw draft request (validation error) +- Cannot withdraw approved request (validation error) +- Cannot withdraw rejected request (validation error) + +#### 4.4 Add authorization tests + +- Only request owner or admin can withdraw their pending request + +## UI Design + +### Draft State +``` +┌─────────────────────────────────────────────────────────────┐ +│ Purchase Request #PR-00001 │ +│ Edit draft request │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────────┐ ┌───────────────┐ │ +│ │ Request Details │ │ Actions │ │ +│ │ │ │ │ │ +│ │ Title: [_________________] │ │ [Save Changes]│ │ +│ │ │ │ │ │ +│ │ Description: │ │ [Submit for │ │ +│ │ [_________________________] │ │ Approval] │ │ +│ │ [_________________________] │ │ │ │ +│ │ │ │ [Delete] │ │ +│ │ Category: [Dropdown_______] │ │ │ │ +│ │ Department: [Dropdown_____] │ │ │ │ +│ │ │ │ │ │ +│ │ Estimated Amount: [€____] │ │ │ │ +│ │ │ │ │ │ +│ │ Business Justification: │ │ │ │ +│ │ [_________________________] │ │ │ │ +│ │ │ │ │ │ +│ └──────────────────────────────────┘ └───────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Pending State (Readonly with Withdraw) +``` +┌─────────────────────────────────────────────────────────────┐ +│ Purchase Request #PR-00001 [PENDING] │ +│ Submitted: 2025-02-13 │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────────┐ ┌───────────────┐ │ +│ │ Request Details │ │ Actions │ │ +│ │ │ │ │ │ +│ │ Title: MacBook Pro │ │ [Withdraw] │ │ +│ │ │ │ │ │ +│ │ Description: │ │ Return to │ │ +│ │ Need laptop for dev │ │ draft status │ │ +│ │ │ │ to edit │ │ +│ │ Category: IT Equipment │ │ │ │ +│ │ Department: Engineering │ │ │ │ +│ │ │ │ │ │ +│ │ Estimated Amount: €1,500.00 │ │ │ │ +│ │ │ │ │ │ +│ │ Business Justification: │ │ │ │ +│ │ New hire equipment │ │ │ │ +│ │ │ │ │ │ +│ └──────────────────────────────────┘ └───────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Approved/Rejected State (Readonly only) +``` +┌─────────────────────────────────────────────────────────────┐ +│ Purchase Request #PR-00001 [APPROVED] │ +│ Submitted: 2025-02-13 │ +│ Approved: 2025-02-13 by John Smith │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ Request Details │ │ +│ │ │ │ +│ │ Title: MacBook Pro │ │ +│ │ │ │ +│ │ ... (all fields readonly) │ │ +│ │ │ │ +│ └──────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## State Machine + +``` + ┌─────────┐ + ┌─────────►│ Draft │◄──────────┐ + │ └────┬────┘ │ + │ │ Update │ Withdraw + │ │ (edit fields) │ + │ ▼ │ + │ ┌─────────┐ │ + Delete│ Submit│ Pending │ │ + │ └────┬────┘ │ + │ │ │ + │ ┌──────┴──────┐ │ + │ ▼ ▼ │ + │ ┌─────────┐ ┌─────────┐ │ + └───┤Approved │ │Rejected │─────┘ + └─────────┘ └─────────┘ +``` + +## Tests to Write + +1. **API Tests** (PurchaseRequestTests.cs) + - Cross-cutting tests: Add withdraw endpoint to `GetAllPurchaseRequestEndpoints` + - Validation test: `Test_WithdrawPurchaseRequest_validation` + - `Can_withdraw_pending_purchase_request` + - `Cannot_withdraw_draft_purchase_request` + - `Cannot_withdraw_approved_purchase_request` + - `Cannot_withdraw_rejected_purchase_request` + - `Cannot_withdraw_nonexistent_purchase_request` + +2. **Unit Tests** (optional) + - `PurchaseRequest.Withdraw_ResetsStatusToDraft` + - `PurchaseRequest.Withdraw_ClearsSubmittedAt` + - `PurchaseRequest.Withdraw_ThrowsForNonPendingStatus` + +## Files to Modify + +1. `ProcureHub/Models/PurchaseRequest.cs` - Add Withdraw method +2. `ProcureHub/Features/PurchaseRequests/Validation/PurchaseRequestErrors.cs` - Add CannotWithdrawNonPending +3. `ProcureHub/Features/PurchaseRequests/WithdrawPurchaseRequest.cs` - New file +4. `ProcureHub.WebApi/Features/PurchaseRequests/Endpoints.cs` - Add withdraw endpoint +5. `ProcureHub.WebApi.Tests/Features/PurchaseRequestTests.cs` - Add tests +6. `ProcureHub.BlazorApp/Components/Pages/Requests/View.razor` - New file +7. `ProcureHub.BlazorApp/Components/Pages/Requests/Index.razor` - Update view button to navigate + +## Success Criteria + +- [ ] User can view any of their requests at `/requests/{id}` +- [ ] Draft requests show editable form with Save, Submit, Delete actions +- [ ] Pending requests show readonly view with Withdraw action +- [ ] Approved/Rejected requests show readonly view with no actions +- [ ] Withdrawing a pending request returns it to Draft status +- [ ] All endpoints require authentication +- [ ] Only request owners/admins can view/edit their requests +- [ ] All tests pass diff --git a/.plans/use-cases/02_view_and_edit_requests_steps.md b/.plans/use-cases/02_view_and_edit_requests_steps.md new file mode 100644 index 0000000..a9cb84d --- /dev/null +++ b/.plans/use-cases/02_view_and_edit_requests_steps.md @@ -0,0 +1,139 @@ +# View/Edit Purchase Requests - Implementation Steps + +## Phase 1: Backend Implementation + +### Step 1: Add Withdraw method to PurchaseRequest model +- [x] Open `ProcureHub/Models/PurchaseRequest.cs` +- [x] Add `Withdraw()` method after `CanDelete()` method (around line 101) +- [x] Method should: + - Check if Status is Pending, return failure if not + - Set Status to Draft + - Set SubmittedAt to null + - Set UpdatedAt to DateTime.UtcNow + - Return Result.Success() + +### Step 2: Add CannotWithdrawNonPending error +- [x] Open `ProcureHub/Features/PurchaseRequests/Validation/PurchaseRequestErrors.cs` +- [x] Add `CannotWithdrawNonPending` error after `CannotDeleteNonDraft` +- [x] Use similar pattern: Error.Validation with appropriate title, detail, and Status field error + +### Step 3: Create WithdrawPurchaseRequest command handler +- [x] Create new file `ProcureHub/Features/PurchaseRequests/WithdrawPurchaseRequest.cs` +- [x] Add Command record with Id property +- [x] Add CommandValidator with Id.NotEmpty() rule +- [x] Add Handler that: + - Loads purchase request by Id + - Returns NotFound if doesn't exist + - Calls pr.Withdraw() + - Saves changes if successful + - Returns result + +### Step 4: Add Withdraw endpoint +- [x] Open `ProcureHub.WebApi/Features/PurchaseRequests/Endpoints.cs` +- [x] Add MapPost endpoint for `/purchase-requests/{id:guid}/withdraw` after Delete endpoint +- [x] Follow pattern of other command endpoints +- [x] Require Requester role authorization +- [x] Produces 204 NoContent and 404 NotFound + +### Step 5: Add API tests for Withdraw endpoint +- [x] Open `ProcureHub.WebApi.Tests/Features/PurchaseRequestTests.cs` +- [x] Add withdraw endpoint to `GetAllPurchaseRequestEndpoints` theory data +- [x] Add `Test_WithdrawPurchaseRequest_validation` method +- [x] Add `Can_withdraw_pending_purchase_request` test +- [x] Add `Cannot_withdraw_draft_purchase_request` test +- [x] Add `Cannot_withdraw_approved_purchase_request` test +- [x] Add `Cannot_withdraw_rejected_purchase_request` test + +## Phase 2: UI Implementation + +### Step 6: Create View/Edit Request page +- [x] Create new file `ProcureHub.BlazorApp/Components/Pages/Requests/View.razor` +- [x] Add `@page "/requests/{Id:guid}"` route +- [x] Add `[Authorize]` attribute +- [x] Inject required handlers: + - GetPurchaseRequestById handler + - UpdatePurchaseRequest handler + - SubmitPurchaseRequest handler + - WithdrawPurchaseRequest handler + - DeletePurchaseRequest handler + - QueryCategories handler + - QueryDepartments handler + - CurrentUserProvider + - NavigationManager + - NotificationService + - ILogger + +### Step 7: Implement page structure +- [x] Add PageTitle and PageHeader +- [x] Create two-column layout (8/4 ratio like New.razor) +- [x] Left column: Request Details card +- [x] Right column: Actions card + +### Step 8: Implement loading state +- [x] Add `_loading` field +- [x] Show RadzenProgressBarCircular while loading +- [x] Load request data in OnInitializedAsync +- [x] Load categories and departments for dropdowns + +### Step 9: Implement Draft view (editable) +- [x] When Status == Draft: + - Show EditForm with FluentValidator + - Show editable fields: Title, Description, Category, Department, Amount, Justification + - Actions: "Save Changes", "Submit for Approval", "Delete" + +### Step 10: Implement Pending view (readonly with withdraw) +- [x] When Status == Pending: + - Show readonly display of all fields (RadzenText, not form inputs) + - Show status badge prominently + - Show SubmittedAt date + - Actions: "Withdraw Request" button + +### Step 11: Implement Approved/Rejected view (readonly) +- [x] When Status is Approved or Rejected: + - Show readonly display of all fields + - Show status badge prominently + - Show SubmittedAt, ReviewedAt, ReviewedBy info + - No action buttons + +### Step 12: Implement action handlers +- [x] SaveChanges method: calls Update handler +- [x] SubmitRequest method: calls Submit handler, navigates on success +- [x] WithdrawRequest method: calls Withdraw handler, refreshes data on success +- [x] DeleteRequest method: calls Delete handler, navigates on success +- [x] All methods show notifications on success/failure + +### Step 13: Update Requests list navigation +- [x] Open `ProcureHub.BlazorApp/Components/Pages/Requests/Index.razor` +- [x] Verify view button navigates to `/requests/{pr.Id}` +- [x] Consider making RequestNumber clickable as well + +## Phase 3: Testing and Validation + +### Step 14: Build and verify +- [x] Run `dotnet build` to ensure no compilation errors +- [x] Run API tests to verify withdraw functionality +- [x] Verify all tests pass + +### Step 15: Manual UI testing +- [ ] Navigate to requests list +- [ ] Click on a Draft request - should show editable form +- [ ] Save changes - should persist +- [ ] Submit - should change to Pending and show readonly view +- [ ] Withdraw - should return to Draft +- [ ] Submit again +- [ ] Login as approver and approve +- [ ] Navigate back to request - should show Approved readonly view +- [ ] Verify no actions available for Approved/Rejected + +## Files Summary + +### New Files +1. `ProcureHub/Features/PurchaseRequests/WithdrawPurchaseRequest.cs` +2. `ProcureHub.BlazorApp/Components/Pages/Requests/View.razor` + +### Modified Files +1. `ProcureHub/Models/PurchaseRequest.cs` +2. `ProcureHub/Features/PurchaseRequests/Validation/PurchaseRequestErrors.cs` +3. `ProcureHub.WebApi/Features/PurchaseRequests/Endpoints.cs` +4. `ProcureHub.WebApi.Tests/Features/PurchaseRequestTests.cs` +5. `ProcureHub.BlazorApp/Components/Pages/Requests/Index.razor` (minor) diff --git a/AGENTS.md b/AGENTS.md index 8b72465..17b9fb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,11 +14,14 @@ # Tech Stack -- This is a full stack app using .Net (C#) for backend API and React (with TanStack Router) for frontend SPA +- This is a full stack app using .Net (C#) for backend API and with two frontends: + - 1) Blazor Server app. This is primary frontend now. IMPORTANT: Make any UI changes in this project unless told otherwise. + - For further details: `ProcureHub.BlazorApp/AGENTS.md` + - 2) React (with TanStack Router) for frontend SPA. Legacy frontend. Do not update unless explicity told. + - For further details: `ProcureHub.WebApp/AGENTS.md` - The API uses ASP.Net Core Minimal APIs, EF Core with Postgres DB, and modern C# code using .Net Core version 10 - The API generates an OpenAPI spec which is converted to a strongly typed `openapi-react-query` client for the web project - Update the client after any API change that updates the OpenAPI spec. Use command given below. -- Detailed guidance for the React app can be found in `ProcureHub.WebApp/AGENTS.md` ## API Notes diff --git a/ProcureHub.BlazorApp/Components/Pages/Requests/Index.razor b/ProcureHub.BlazorApp/Components/Pages/Requests/Index.razor index 887570f..439ff38 100644 --- a/ProcureHub.BlazorApp/Components/Pages/Requests/Index.razor +++ b/ProcureHub.BlazorApp/Components/Pages/Requests/Index.razor @@ -78,7 +78,7 @@ diff --git a/ProcureHub.BlazorApp/Components/Pages/Requests/New.razor b/ProcureHub.BlazorApp/Components/Pages/Requests/New.razor index 89217fd..72883a2 100644 --- a/ProcureHub.BlazorApp/Components/Pages/Requests/New.razor +++ b/ProcureHub.BlazorApp/Components/Pages/Requests/New.razor @@ -23,116 +23,55 @@ - - - - - @* Left column: Request Details form *@ - - - - - Request Details - - Provide information about your purchase request - - - - @if (_errorMessage != null) - { - - @_errorMessage - - } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @* Right column: Actions *@ - - - - - Actions - - Save as draft or submit for approval - - - - - - + @if (_errorMessage != null) + { + + @_errorMessage + + } + + + @* Left column: Request Details form *@ + + + + + + + @* Right column: Actions *@ + + + + + Actions + + Save as draft or submit for approval + - - - - + + + + + + + + @code { - private readonly PurchaseRequestFormModel _model = new(); + private PurchaseRequestFormModel _model = new(); private EditContext? _editContext; private QueryCategories.Response[] _categories = []; private QueryDepartments.Response[] _departments = []; diff --git a/ProcureHub.BlazorApp/Components/Pages/Requests/View.razor b/ProcureHub.BlazorApp/Components/Pages/Requests/View.razor new file mode 100644 index 0000000..498b1b9 --- /dev/null +++ b/ProcureHub.BlazorApp/Components/Pages/Requests/View.razor @@ -0,0 +1,436 @@ +@page "/requests/{Id:guid}" +@attribute [Authorize] +@layout AuthenticatedLayout + +@using Blazilla +@using ProcureHub.Features.Categories +@using ProcureHub.Features.Departments +@using ProcureHub.Features.PurchaseRequests +@using ProcureHub.Infrastructure +@using ProcureHub.Infrastructure.Authentication +@using ProcureHub.Models + +@inject IQueryHandler> GetByIdHandler +@inject IQueryHandler QueryCategoriesHandler +@inject IQueryHandler QueryDepartmentsHandler +@inject ICommandHandler UpdateHandler +@inject ICommandHandler SubmitHandler +@inject ICommandHandler WithdrawHandler +@inject ICommandHandler DeleteHandler +@inject ICurrentUserProvider CurrentUserProvider +@inject NotificationService NotificationService +@inject DialogService DialogService +@inject NavigationManager Navigation +@inject ILogger Logger + +@GetPageTitle() + +@if (_loading) +{ + + + +} +else if (_errorMessage != null) +{ + + + + @_errorMessage + + + +} +else if (_request != null) +{ + + + + @if (_request.Status == PurchaseRequestStatus.Draft) + { + + } + + + + + @* Left column: Request Details *@ + + + + @* Status header for readonly views *@ + @if (_request.Status != PurchaseRequestStatus.Draft) + { + + + @_request.RequestNumber + + + + + @if (_request.SubmittedAt.HasValue) + { + + Submitted: @_request.SubmittedAt.Value.ToString("yyyy-MM-dd HH:mm") + + } + + @if (_request.Status is PurchaseRequestStatus.Approved or PurchaseRequestStatus.Rejected + && _request.ReviewedAt.HasValue && _request.ReviewedBy != null) + { + + @(_request.Status == PurchaseRequestStatus.Approved ? "Approved" : "Rejected"): + @_request.ReviewedAt.Value.ToString("yyyy-MM-dd HH:mm") by @_request.ReviewedBy.FirstName @_request.ReviewedBy.LastName + + } + +
+ } + + @if (_isEditing) + { + @* Editable form for Draft *@ + if (_saveErrorMessage != null) + { + + @_saveErrorMessage + + } + + + } + else + { + @* Readonly view for non-Draft *@ + + } +
+
+
+ + @* Right column: Actions *@ + + + + @if (_request.Status == PurchaseRequestStatus.Draft) + { + + Actions + + Save changes, submit for approval, or delete + + + + + + + + + } + else if (_request.Status == PurchaseRequestStatus.Pending) + { + + Actions + + Withdraw to draft status if you need to make changes + + + + + } + else + { + @* Approved or Rejected - no actions *@ + + Status + + This request has been finalized and cannot be modified. + + + } + + + +
+
+} + +@code { + [Parameter] public Guid Id { get; set; } + + private GetPurchaseRequestById.Response? _request; + private PurchaseRequestFormModel _model = new(); + private EditContext? _editContext; + private QueryCategories.Response[] _categories = []; + private QueryDepartments.Response[] _departments = []; + private bool _loading = true; + private bool _isProcessing; + private bool _isEditing; + private string? _errorMessage; + private string? _saveErrorMessage; + + protected override async Task OnInitializedAsync() + { + _categories = await QueryCategoriesHandler.HandleAsync(new QueryCategories.Request(), CancellationToken.None); + _departments = await QueryDepartmentsHandler.HandleAsync(new QueryDepartments.Request(), CancellationToken.None); + await LoadRequest(); + } + + private async Task LoadRequest() + { + _loading = true; + _errorMessage = null; + + try + { + var currentUser = await CurrentUserProvider.GetCurrentUserAsync(); + if (currentUser.UserId is null) + { + _errorMessage = "Authentication error. Please sign out and sign in again."; + return; + } + + var result = await GetByIdHandler.HandleAsync( + new GetPurchaseRequestById.Request(Id, currentUser.UserId.Value.ToString()), + CancellationToken.None); + + if (result.IsFailure) + { + _errorMessage = result.Error.Message; + return; + } + + _request = result.Value; + _isEditing = _request.Status == PurchaseRequestStatus.Draft; + + // Populate model for editing + if (_isEditing) + { + _model = new PurchaseRequestFormModel + { + Title = _request.Title, + Description = _request.Description, + EstimatedAmount = _request.EstimatedAmount, + BusinessJustification = _request.BusinessJustification, + CategoryId = _request.Category.Id, + DepartmentId = _request.Department.Id + }; + _editContext = new EditContext(_model); + } + } + finally + { + _loading = false; + } + } + + private string GetPageTitle() + { + if (_request == null) return "Purchase Request"; + + return _request.Status switch + { + PurchaseRequestStatus.Draft => "Edit Draft Request", + PurchaseRequestStatus.Pending => $"Request {_request.RequestNumber}", + PurchaseRequestStatus.Approved => $"Request {_request.RequestNumber}", + PurchaseRequestStatus.Rejected => $"Request {_request.RequestNumber}", + _ => "Purchase Request" + }; + } + + private string GetPageSubtitle() + { + if (_request == null) return ""; + + return _request.Status switch + { + PurchaseRequestStatus.Draft => "Edit your draft purchase request before submitting", + PurchaseRequestStatus.Pending => "View your submitted request awaiting approval", + PurchaseRequestStatus.Approved => "View your approved purchase request", + PurchaseRequestStatus.Rejected => "View your rejected purchase request", + _ => "" + }; + } + + private async Task SaveChanges() + { + if (_editContext is null || !_editContext.Validate()) + { + return; + } + + _saveErrorMessage = null; + _isProcessing = true; + + try + { + var command = new UpdatePurchaseRequest.Command( + Id, + _model.Title, + _model.Description, + _model.EstimatedAmount, + _model.BusinessJustification, + _model.CategoryId!.Value, + _model.DepartmentId!.Value + ); + + var result = await UpdateHandler.HandleAsync(command, CancellationToken.None); + + if (result.IsFailure) + { + _saveErrorMessage = result.Error.Message; + return; + } + + NotificationService.Notify(NotificationSeverity.Success, "Saved", "Changes saved successfully"); + await LoadRequest(); // Refresh to get updated timestamps + } + finally + { + _isProcessing = false; + } + } + + private async Task SubmitRequest() + { + // First save any changes + if (_editContext is null || !_editContext.Validate()) + { + return; + } + + _saveErrorMessage = null; + _isProcessing = true; + + try + { + // Save first + var updateCommand = new UpdatePurchaseRequest.Command( + Id, + _model.Title, + _model.Description, + _model.EstimatedAmount, + _model.BusinessJustification, + _model.CategoryId!.Value, + _model.DepartmentId!.Value + ); + + var updateResult = await UpdateHandler.HandleAsync(updateCommand, CancellationToken.None); + + if (updateResult.IsFailure) + { + _saveErrorMessage = updateResult.Error.Message; + return; + } + + // Then submit + var submitResult = await SubmitHandler.HandleAsync(new SubmitPurchaseRequest.Command(Id), CancellationToken.None); + + if (submitResult.IsFailure) + { + _saveErrorMessage = submitResult.Error.Message; + return; + } + + NotificationService.Notify(NotificationSeverity.Success, "Submitted", "Purchase request submitted for approval"); + await LoadRequest(); // Refresh to show readonly view + } + finally + { + _isProcessing = false; + } + } + + private async Task WithdrawRequest() + { + _isProcessing = true; + + try + { + var result = await WithdrawHandler.HandleAsync(new WithdrawPurchaseRequest.Command(Id), CancellationToken.None); + + if (result.IsFailure) + { + NotificationService.Notify(NotificationSeverity.Error, "Error", result.Error.Message); + return; + } + + NotificationService.Notify(NotificationSeverity.Success, "Withdrawn", "Request returned to draft status"); + await LoadRequest(); // Refresh to show editable view + } + finally + { + _isProcessing = false; + } + } + + private async Task DeleteRequest() + { + var confirm = await DialogService.Confirm( + "Are you sure you want to delete this draft request? This action cannot be undone.", + "Delete Request", + new ConfirmOptions { OkButtonText = "Delete", CancelButtonText = "Cancel" }); + + if (confirm != true) + { + return; + } + + _isProcessing = true; + + try + { + var result = await DeleteHandler.HandleAsync(new DeletePurchaseRequest.Command(Id), CancellationToken.None); + + if (result.IsFailure) + { + NotificationService.Notify(NotificationSeverity.Error, "Error", result.Error.Message); + return; + } + + NotificationService.Notify(NotificationSeverity.Success, "Deleted", "Request deleted successfully"); + Navigation.NavigateTo("/requests"); + } + finally + { + _isProcessing = false; + } + } +} diff --git a/ProcureHub.BlazorApp/Components/Shared/PurchaseRequestForm.razor b/ProcureHub.BlazorApp/Components/Shared/PurchaseRequestForm.razor new file mode 100644 index 0000000..4871fc2 --- /dev/null +++ b/ProcureHub.BlazorApp/Components/Shared/PurchaseRequestForm.razor @@ -0,0 +1,155 @@ +@using Blazilla +@using ProcureHub.Features.Categories +@using ProcureHub.Features.Departments +@using ProcureHub.Features.PurchaseRequests +@using ProcureHub.BlazorApp.Components.Pages.Requests + +@if (IsReadOnly) +{ + + + + @(Value?.Title ?? "-") + + + @if (!string.IsNullOrEmpty(Value?.Description)) + { + + + @Value.Description + + } + + + + + + @(CategoryName ?? "-") + + + + + + @(DepartmentName ?? "-") + + + + + + + @(Value?.EstimatedAmount.ToString("C") ?? "-") + + + @if (!string.IsNullOrEmpty(Value?.BusinessJustification)) + { + + + @Value.BusinessJustification + + } + + @if (ShowRequester) + { + + + @RequesterName + + } + +} +else +{ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +} + +@code { + [Parameter] + public PurchaseRequestFormModel? Value { get; set; } + + [Parameter] + public EventCallback ValueChanged { get; set; } + + [Parameter] + public bool IsReadOnly { get; set; } + + [Parameter] + public bool ShowRequester { get; set; } + + [Parameter] + public string? RequesterName { get; set; } + + [Parameter] + public QueryCategories.Response[] Categories { get; set; } = []; + + [Parameter] + public QueryDepartments.Response[] Departments { get; set; } = []; + + [Parameter] + public EditContext? EditContext { get; set; } + + private string? CategoryName => Value?.CategoryId.HasValue == true + ? Categories.FirstOrDefault(c => c.Id == Value.CategoryId)?.Name + : null; + + private string? DepartmentName => Value?.DepartmentId.HasValue == true + ? Departments.FirstOrDefault(d => d.Id == Value.DepartmentId)?.Name + : null; +} diff --git a/ProcureHub.WebApi.Tests/Features/PurchaseRequestTests.cs b/ProcureHub.WebApi.Tests/Features/PurchaseRequestTests.cs index f0c00c5..4468ebb 100644 --- a/ProcureHub.WebApi.Tests/Features/PurchaseRequestTests.cs +++ b/ProcureHub.WebApi.Tests/Features/PurchaseRequestTests.cs @@ -57,7 +57,8 @@ public static TheoryData GetAllPurchaseRequestEndpoints() new EndpointTestOptions { RequiresAdmin = true }), new EndpointInfo("/purchase-requests/{id}/reject", "POST", "RejectPurchaseRequest", new EndpointTestOptions { RequiresAdmin = true }), - new EndpointInfo("/purchase-requests/{id}", "DELETE", "DeletePurchaseRequest") + new EndpointInfo("/purchase-requests/{id}", "DELETE", "DeletePurchaseRequest"), + new EndpointInfo("/purchase-requests/{id}/withdraw", "POST", "WithdrawPurchaseRequest") }; } @@ -222,6 +223,12 @@ public async Task Test_DeletePurchaseRequest_validation() // No validation - id comes from route only } + [Fact] + public async Task Test_WithdrawPurchaseRequest_validation() + { + // No validation - id comes from route only + } + #endregion } @@ -777,6 +784,162 @@ await deleteResp.AssertValidationProblemAsync( }); } + [Fact] + public async Task Can_withdraw_pending_purchase_request() + { + await LoginAsAdminAsync(); + var categoryId = await CreateCategoryAsync(); + var departmentId = await CreateDepartmentAsync(); + await CreateUserAsync("requester@example.com", ValidPassword, [RoleNames.Requester], departmentId); + + await LoginAsync("requester@example.com", ValidPassword); + var prId = await CreatePurchaseRequestAsync(categoryId, departmentId); + + // Submit to make it Pending + var submitResp = await HttpClient.PostAsync($"/purchase-requests/{prId}/submit", null); + Assert.Equal(HttpStatusCode.NoContent, submitResp.StatusCode); + + // Verify Pending status + var getPending = await HttpClient.GetAsync($"/purchase-requests/{prId}") + .ReadJsonAsync>(); + Assert.Equal(PurchaseRequestStatus.Pending, getPending.Data.Status); + Assert.NotNull(getPending.Data.SubmittedAt); + + // Withdraw the request + var withdrawResp = await HttpClient.PostAsync($"/purchase-requests/{prId}/withdraw", null); + Assert.Equal(HttpStatusCode.NoContent, withdrawResp.StatusCode); + + // Verify back to Draft status with SubmittedAt cleared + var getDraft = await HttpClient.GetAsync($"/purchase-requests/{prId}") + .ReadJsonAsync>(); + Assert.Equal(PurchaseRequestStatus.Draft, getDraft.Data.Status); + Assert.Null(getDraft.Data.SubmittedAt); + } + + [Fact] + public async Task Cannot_withdraw_draft_purchase_request() + { + await LoginAsAdminAsync(); + var categoryId = await CreateCategoryAsync(); + var departmentId = await CreateDepartmentAsync(); + await CreateUserAsync("requester@example.com", ValidPassword, [RoleNames.Requester], departmentId); + + await LoginAsync("requester@example.com", ValidPassword); + var prId = await CreatePurchaseRequestAsync(categoryId, departmentId); + + // Try to withdraw a draft request + var withdrawResp = await HttpClient.PostAsync($"/purchase-requests/{prId}/withdraw", null); + + await withdrawResp.AssertValidationProblemAsync( + title: "Invalid status transition", + detail: "PurchaseRequest.InvalidStatusTransition", + errors: new Dictionary + { + ["Status"] = ["Can only withdraw purchase requests in Pending status."] + }); + + // Verify still Draft + var getDraft = await HttpClient.GetAsync($"/purchase-requests/{prId}") + .ReadJsonAsync>(); + Assert.Equal(PurchaseRequestStatus.Draft, getDraft.Data.Status); + } + + [Fact] + public async Task Cannot_withdraw_approved_purchase_request() + { + await LoginAsAdminAsync(); + var categoryId = await CreateCategoryAsync(); + var departmentId = await CreateDepartmentAsync(); + await CreateUserAsync("requester@example.com", ValidPassword, [RoleNames.Requester], departmentId); + await CreateUserAsync("approver@example.com", ValidPassword, [RoleNames.Approver], departmentId); + + await LoginAsync("requester@example.com", ValidPassword); + var prId = await CreatePurchaseRequestAsync(categoryId, departmentId); + + // Submit + await HttpClient.PostAsync($"/purchase-requests/{prId}/submit", null); + + // Login as approver and approve + await LoginAsync("approver@example.com", ValidPassword); + await HttpClient.PostAsync($"/purchase-requests/{prId}/approve", null); + + // Login back as requester to try withdraw + await LoginAsync("requester@example.com", ValidPassword); + + // Try to withdraw + var withdrawResp = await HttpClient.PostAsync($"/purchase-requests/{prId}/withdraw", null); + + await withdrawResp.AssertValidationProblemAsync( + title: "Invalid status transition", + detail: "PurchaseRequest.InvalidStatusTransition", + errors: new Dictionary + { + ["Status"] = ["Can only withdraw purchase requests in Pending status."] + }); + + // Verify still Approved + var getApproved = await HttpClient.GetAsync($"/purchase-requests/{prId}") + .ReadJsonAsync>(); + Assert.Equal(PurchaseRequestStatus.Approved, getApproved.Data.Status); + } + + [Fact] + public async Task Cannot_withdraw_rejected_purchase_request() + { + await LoginAsAdminAsync(); + var categoryId = await CreateCategoryAsync(); + var departmentId = await CreateDepartmentAsync(); + await CreateUserAsync("requester@example.com", ValidPassword, [RoleNames.Requester], departmentId); + await CreateUserAsync("approver@example.com", ValidPassword, [RoleNames.Approver], departmentId); + + await LoginAsync("requester@example.com", ValidPassword); + var prId = await CreatePurchaseRequestAsync(categoryId, departmentId); + + // Submit + await HttpClient.PostAsync($"/purchase-requests/{prId}/submit", null); + + // Login as approver and reject + await LoginAsync("approver@example.com", ValidPassword); + await HttpClient.PostAsync($"/purchase-requests/{prId}/reject", null); + + // Login back as requester to try withdraw + await LoginAsync("requester@example.com", ValidPassword); + + // Try to withdraw + var withdrawResp = await HttpClient.PostAsync($"/purchase-requests/{prId}/withdraw", null); + + await withdrawResp.AssertValidationProblemAsync( + title: "Invalid status transition", + detail: "PurchaseRequest.InvalidStatusTransition", + errors: new Dictionary + { + ["Status"] = ["Can only withdraw purchase requests in Pending status."] + }); + + // Verify still Rejected + var getRejected = await HttpClient.GetAsync($"/purchase-requests/{prId}") + .ReadJsonAsync>(); + Assert.Equal(PurchaseRequestStatus.Rejected, getRejected.Data.Status); + } + + [Fact] + public async Task Cannot_withdraw_nonexistent_purchase_request() + { + await LoginAsAdminAsync(); + await CreateUserAsync("requester@example.com", ValidPassword, [RoleNames.Requester]); + + await LoginAsync("requester@example.com", ValidPassword); + + var nonexistentId = Guid.NewGuid(); + var withdrawResp = await HttpClient.PostAsync($"/purchase-requests/{nonexistentId}/withdraw", null); + + await withdrawResp.AssertProblemDetailsAsync( + HttpStatusCode.NotFound, + "Purchase request not found", + "NotFound", + $"POST /purchase-requests/{nonexistentId}/withdraw"); + } + [Fact] public async Task Can_query_purchase_requests_by_status() { diff --git a/ProcureHub.WebApi/Features/PurchaseRequests/Endpoints.cs b/ProcureHub.WebApi/Features/PurchaseRequests/Endpoints.cs index 1314c7a..53239c4 100644 --- a/ProcureHub.WebApi/Features/PurchaseRequests/Endpoints.cs +++ b/ProcureHub.WebApi/Features/PurchaseRequests/Endpoints.cs @@ -200,5 +200,22 @@ Guid id .WithName(nameof(DeletePurchaseRequest)) .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status404NotFound); + + group.MapPost("/purchase-requests/{id:guid}/withdraw", async ( + [FromServices] ICommandHandler handler, + CancellationToken token, + Guid id + ) => + { + var result = await handler.HandleAsync(new WithdrawPurchaseRequest.Command(id), token); + return result.Match( + Results.NoContent, + error => error.ToProblemDetails() + ); + }) + .RequireAuthorization(RolePolicyNames.Requester) + .WithName(nameof(WithdrawPurchaseRequest)) + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status404NotFound); } } diff --git a/ProcureHub/Features/PurchaseRequests/Validation/PurchaseRequestErrors.cs b/ProcureHub/Features/PurchaseRequests/Validation/PurchaseRequestErrors.cs index 35cae99..1670e06 100644 --- a/ProcureHub/Features/PurchaseRequests/Validation/PurchaseRequestErrors.cs +++ b/ProcureHub/Features/PurchaseRequests/Validation/PurchaseRequestErrors.cs @@ -30,6 +30,10 @@ public static class PurchaseRequestErrors { ["Status"] = ["Only purchase requests in Draft status can be deleted."] }); + public static readonly Error CannotWithdrawNonPending = Error.Validation("PurchaseRequest.InvalidStatusTransition", "Invalid status transition", new Dictionary + { + ["Status"] = ["Can only withdraw purchase requests in Pending status."] + }); public static readonly Error CategoryNotFound = Error.Validation("PurchaseRequest.CategoryNotFound", "Category not found", new Dictionary { ["CategoryId"] = ["The specified category does not exist."] diff --git a/ProcureHub/Features/PurchaseRequests/WithdrawPurchaseRequest.cs b/ProcureHub/Features/PurchaseRequests/WithdrawPurchaseRequest.cs new file mode 100644 index 0000000..8b25982 --- /dev/null +++ b/ProcureHub/Features/PurchaseRequests/WithdrawPurchaseRequest.cs @@ -0,0 +1,44 @@ +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using ProcureHub.Common; +using ProcureHub.Features.PurchaseRequests.Validation; +using ProcureHub.Infrastructure; + +namespace ProcureHub.Features.PurchaseRequests; + +public static class WithdrawPurchaseRequest +{ + public record Command(Guid Id); + + public class CommandValidator : AbstractValidator + { + public CommandValidator() + { + RuleFor(r => r.Id).NotEmpty(); + } + } + + public class Handler(ApplicationDbContext dbContext) : ICommandHandler + { + public async Task HandleAsync(Command command, CancellationToken token) + { + var purchaseRequest = await dbContext.PurchaseRequests + .FirstOrDefaultAsync(pr => pr.Id == command.Id, token); + + if (purchaseRequest is null) + { + return Result.Failure(PurchaseRequestErrors.NotFound); + } + + var result = purchaseRequest.Withdraw(); + if (result.IsFailure) + { + return result; + } + + await dbContext.SaveChangesAsync(token); + + return Result.Success(); + } + } +} diff --git a/ProcureHub/Models/PurchaseRequest.cs b/ProcureHub/Models/PurchaseRequest.cs index c48ff48..03eafbd 100644 --- a/ProcureHub/Models/PurchaseRequest.cs +++ b/ProcureHub/Models/PurchaseRequest.cs @@ -99,6 +99,20 @@ public Result CanDelete() ? Result.Success() : Result.Failure(PurchaseRequestErrors.CannotDeleteNonDraft); } + + public Result Withdraw() + { + if (Status != PurchaseRequestStatus.Pending) + { + return Result.Failure(PurchaseRequestErrors.CannotWithdrawNonPending); + } + + Status = PurchaseRequestStatus.Draft; + SubmittedAt = null; + UpdatedAt = DateTime.UtcNow; + + return Result.Success(); + } } public enum PurchaseRequestStatus