diff --git a/_bmad-output/implementation-artifacts/11-7-create-expense-from-work-order.md b/_bmad-output/implementation-artifacts/11-7-create-expense-from-work-order.md new file mode 100644 index 0000000..1c24a2c --- /dev/null +++ b/_bmad-output/implementation-artifacts/11-7-create-expense-from-work-order.md @@ -0,0 +1,514 @@ +# Story 11.7: Create Expense from Work Order + +Status: review-ready + +## Story + +As a **property owner**, +I want **to create an expense directly from a work order**, +So that **I can quickly log costs as they occur on a job without navigating away from the work order**. + +## Acceptance Criteria + +### AC #1: Create Expense Button on Work Order Detail Page + +**Given** I am on a work order detail page +**When** I view the Linked Expenses section +**Then** I see a "Create Expense" button alongside the existing "Link Existing Expense" button +**And** the button has icon `add_circle` and text "Create Expense" + +### AC #2: Create Expense Dialog with Pre-populated Fields + +**Given** I click "Create Expense" on the work order detail page +**When** the dialog opens +**Then** I see a dialog titled "Create Expense for Work Order" +**And** the property is displayed as a locked label (from the work order) +**And** the work order description is shown for context (read-only) +**And** the category is pre-selected if the work order has a category (editable) +**And** the date defaults to today (editable) +**And** the amount field is empty and required +**And** the description field is empty (optional) +**And** I see "Create" and "Cancel" buttons + +### AC #3: Successful Expense Creation + +**Given** I fill in at least the amount and category +**When** I click "Create" +**Then** the expense is created via `POST /api/v1/expenses` with `workOrderId` set +**And** I see snackbar "Expense created" +**And** the dialog closes +**And** the expense appears in the Linked Expenses list on the work order +**And** the linked expenses total updates to include the new amount + +### AC #4: Cancel Without Creating + +**Given** the Create Expense dialog is open +**When** I click "Cancel" +**Then** no expense is created +**And** the dialog closes + +### AC #5: Validation + +**Given** I am in the Create Expense dialog +**When** I leave the amount field empty or enter 0 +**Then** I see validation error "Amount is required" +**And** the "Create" button is disabled until valid + +**Given** I am in the Create Expense dialog +**When** I leave the category unselected +**Then** I see validation error "Category is required" + +### AC #6: Error Handling + +**Given** expense creation fails (network error or server error) +**When** the API returns an error +**Then** I see snackbar "Failed to create expense" +**And** the dialog remains open for retry + +### AC #7: Multiple Expenses per Work Order + +**Given** I just created an expense for this work order +**When** I click "Create Expense" again +**Then** the dialog opens fresh (no leftover data from previous submission) +**And** I can create another expense linked to the same work order + +## Tasks / Subtasks + +### Task 1: Create the Dialog Component (AC: #2, #4, #5) + +- [x] 1.1 Create `frontend/src/app/features/expenses/components/create-expense-from-wo-dialog/create-expense-from-wo-dialog.component.ts` + - Standalone component with inline template and styles + - Inject `MAT_DIALOG_DATA` receiving `CreateExpenseFromWoDialogData`: + ```typescript + export interface CreateExpenseFromWoDialogData { + workOrderId: string; + propertyId: string; + propertyName: string; + categoryId?: string; + workOrderDescription: string; + } + ``` + - Inject `MatDialogRef`, `FormBuilder`, `ExpenseService`, `MatSnackBar` + - Import `ReactiveFormsModule`, `MatDialogModule`, `MatFormFieldModule`, `MatInputModule`, `MatSelectModule`, `MatButtonModule`, `MatProgressSpinnerModule`, `MatDatepickerModule` + +- [x] 1.2 Create the form with fields: + ```typescript + form = this.fb.group({ + amount: [null as number | null, [Validators.required, Validators.min(0.01)]], + date: [new Date().toISOString().split('T')[0], [Validators.required]], + categoryId: [this.data.categoryId || '', [Validators.required]], + description: [''], + }); + ``` + +- [x] 1.3 Load categories on init using `ExpenseStore.loadCategories()` pattern: + ```typescript + private readonly expenseStore = inject(ExpenseStore); + categories = this.expenseStore.sortedCategories; + ``` + Call `this.expenseStore.loadCategories()` in constructor or `ngOnInit`. + +- [x] 1.4 Add dialog template: + ```html +

Create Expense for Work Order

+ +

Property: {{ data.propertyName }}

+

Work Order: {{ data.workOrderDescription }}

+
+ + Amount + + + @if (form.controls.amount.hasError('required')) { + Amount is required + } @else if (form.controls.amount.hasError('min')) { + Amount must be greater than 0 + } + + + + Date + + @if (form.controls.date.hasError('required')) { + Date is required + } + + + + Category + + @for (cat of categories(); track cat.id) { + {{ cat.name }} + } + + @if (form.controls.categoryId.hasError('required')) { + Category is required + } + + + + Description (optional) + + +
+
+ + + + + ``` + +- [x] 1.5 Add styles: + ```scss + .full-width { width: 100%; } + .property-label { + font-size: 0.9em; + color: var(--mat-sys-on-surface-variant); + margin-bottom: 4px; + } + .wo-context { + font-size: 0.85em; + color: var(--mat-sys-on-surface-variant); + margin-bottom: 16px; + font-style: italic; + } + mat-dialog-content { min-width: 400px; } + @media (max-width: 600px) { + mat-dialog-content { min-width: unset; } + } + ``` + +### Task 2: Implement Create Expense Logic (AC: #3, #6) + +- [x] 2.1 Add `isSubmitting` signal and `onSubmit()` method: + ```typescript + isSubmitting = signal(false); + + onSubmit(): void { + if (this.form.invalid || this.isSubmitting()) return; + this.isSubmitting.set(true); + + const createRequest: CreateExpenseRequest = { + propertyId: this.data.propertyId, + amount: this.form.value.amount!, + date: this.form.value.date!, + categoryId: this.form.value.categoryId!, + description: this.form.value.description || undefined, + workOrderId: this.data.workOrderId, + }; + + this.expenseService.createExpense(createRequest).subscribe({ + next: () => { + this.snackBar.open('Expense created', 'Close', { duration: 3000 }); + this.dialogRef.close({ created: true }); + }, + error: () => { + this.isSubmitting.set(false); + this.snackBar.open('Failed to create expense', 'Close', { duration: 3000 }); + }, + }); + } + ``` + +- [x] 2.2 Define the dialog result interface: + ```typescript + export interface CreateExpenseFromWoDialogResult { + created: boolean; + } + ``` + +### Task 3: Add Create Expense Button to Work Order Detail (AC: #1, #3, #7) + +- [x] 3.1 In `work-order-detail.component.ts`, add a "Create Expense" button in the Linked Expenses header alongside the existing "Link Existing Expense" button: + ```html + + Linked Expenses +
+ + +
+
+ ``` + +- [x] 3.2 Add the `openCreateExpenseDialog()` method: + ```typescript + openCreateExpenseDialog(): void { + const wo = this.workOrder(); + if (!wo) return; + + const dialogRef = this.dialog.open(CreateExpenseFromWoDialogComponent, { + width: '500px', + data: { + workOrderId: wo.id, + propertyId: wo.propertyId, + propertyName: wo.propertyName, + categoryId: wo.categoryId, + workOrderDescription: wo.description, + } as CreateExpenseFromWoDialogData, + }); + + dialogRef.afterClosed().subscribe((result: CreateExpenseFromWoDialogResult | undefined) => { + if (result?.created) { + this.loadLinkedExpenses(); + } + }); + } + ``` + +- [x] 3.3 Add imports: + ```typescript + import { CreateExpenseFromWoDialogComponent, CreateExpenseFromWoDialogData, CreateExpenseFromWoDialogResult } from '../../expenses/components/create-expense-from-wo-dialog/create-expense-from-wo-dialog.component'; + ``` + +- [x] 3.4 Add `.expenses-actions` wrapper styles if not already present: + ```scss + .expenses-actions { + display: flex; + gap: 8px; + align-items: center; + } + ``` + +### Task 4: Frontend Unit Tests (AC: ALL) + +- [x] 4.1 Test `create-expense-from-wo-dialog.component.spec.ts`: + - Dialog renders with property name displayed (not editable) + - Dialog renders with work order description for context + - Category pre-selected from work order data when provided + - Date defaults to today + - Amount field validates as required + - Amount field validates min 0.01 + - Category field validates as required + - "Create" button disabled when form invalid + - "Create" button disabled during submission + - Successful creation calls `expenseService.createExpense` with correct data including workOrderId + - Shows "Expense created" snackbar on success + - Shows "Failed to create expense" snackbar on error + - Dialog stays open on error for retry + - Cancel closes dialog without result + - Dialog returns `{ created: true }` on success + - Categories loaded from store + +- [x] 4.2 Test `work-order-detail.component.spec.ts` (additions): + - Shows "Create Expense" button in Linked Expenses section + - `openCreateExpenseDialog()` opens dialog with correct data from work order + - After dialog closes with `{ created: true }`, refreshes linked expenses + - After dialog closes without result (cancel), no refresh + +## Dev Notes + +### Architecture: Frontend-Only Story + +All backend work was completed in Story 11-1 (Expense-WorkOrder Relationship). This story is **100% frontend work**. No backend changes, no migrations, no API client regeneration needed. + +The backend already supports: +- `POST /api/v1/expenses` — Create expense with `workOrderId` field (single API call!) +- `GET /api/v1/work-orders/{id}/expenses` — Get linked expenses (for refresh) + +### Critical: Single API Call (Simpler than Story 11-6) + +Unlike Story 11-6 (create WO from expense) which requires a two-step API sequence, this story uses a **single API call**. The `CreateExpenseRequest` already includes `workOrderId` as an optional field, so we set it at creation time. No fetch-then-update dance needed. + +``` +Story 11-6 flow: POST work-order → GET expense → PUT expense (3 calls, partial failure handling) +Story 11-7 flow: POST expense with workOrderId (1 call, simple error handling) +``` + +This makes the dialog significantly simpler — no partial failure states to handle. + +### CreateExpenseRequest Shape (from expense.service.ts) + +```typescript +export interface CreateExpenseRequest { + propertyId: string; + amount: number; + date: string; // ISO date string (YYYY-MM-DD) + categoryId: string; + description?: string; + workOrderId?: string; // Set this from dialog data +} +``` + +### Dialog vs Navigation Decision + +**Dialog chosen** (not navigation to expense workspace) because: +- Keeps user in work order context +- Faster for logging multiple expenses against same work order +- Matches existing patterns (`link-expense-dialog`, `create-wo-from-expense-dialog`) +- Avoids complex query param state management + +### Existing Services to Reuse + +| Service | Method | Purpose | +|---------|--------|---------| +| `ExpenseService` | `createExpense(request)` | Create the expense with workOrderId | +| `ExpenseStore` | `loadCategories()` / `sortedCategories` | Populate category dropdown | +| `WorkOrderDetailComponent` | `loadLinkedExpenses()` | Refresh linked expenses after creation | + +**DO NOT** create new services or API endpoints. Everything needed already exists. + +### New Files to Create + +| File | Purpose | +|------|---------| +| `frontend/src/app/features/expenses/components/create-expense-from-wo-dialog/create-expense-from-wo-dialog.component.ts` | Dialog for creating expense from work order | +| `frontend/src/app/features/expenses/components/create-expense-from-wo-dialog/create-expense-from-wo-dialog.component.spec.ts` | Unit tests | + +### Files to Modify + +| File | Change | +|------|--------| +| `frontend/src/app/features/work-orders/work-order-detail/work-order-detail.component.ts` | Add "Create Expense" button, dialog opener, refresh on success | +| `frontend/src/app/features/work-orders/work-order-detail/work-order-detail.component.spec.ts` | Add tests for create expense button and dialog | + +### Pattern: Follow CreateWoFromExpenseDialogComponent (Reverse) + +The `create-wo-from-expense-dialog.component.ts` (Story 11-6) is the reverse pattern. Key adaptations: +- **Simpler submit flow** — single API call instead of two-step +- **Amount field** — required numeric input (not present in WO creation) +- **Date field** — defaults to today, required (not present in WO creation) +- **No vendor/status fields** — expenses don't have these +- **workOrderId set at creation** — no separate linking step needed + +### Pattern: Follow Existing Category Loading + +Use `ExpenseStore.loadCategories()` and `sortedCategories` computed signal, which caches categories and sorts them. This is the same pattern used in `expense-form.component.ts`. + +### WorkOrderDto Shape (from work-order.service.ts) + +```typescript +interface WorkOrderDto { + id: string; + propertyId: string; + propertyName: string; + vendorId?: string; + vendorName?: string; + isDiy: boolean; + categoryId?: string; + categoryName?: string; + status: string; + description: string; + createdAt: string; + createdByUserId: string; + tags: WorkOrderTagDto[]; + primaryPhotoThumbnailUrl?: string; +} +``` + +### Import Requirements + +Dialog component: +```typescript +import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatButtonModule } from '@angular/material/button'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { ExpenseService, CreateExpenseRequest } from '../../services/expense.service'; +import { ExpenseStore } from '../../stores/expense.store'; +``` + +Work order detail additions: +```typescript +import { CreateExpenseFromWoDialogComponent, CreateExpenseFromWoDialogData, CreateExpenseFromWoDialogResult } from '../../expenses/components/create-expense-from-wo-dialog/create-expense-from-wo-dialog.component'; +``` + +### Previous Story Intelligence (11.6) + +From Story 11.6 implementation: +- Frontend: 2,219 tests pass (2,180 base + 39 from 11.6), 0 failures +- `MatDialog` already injected in `work-order-detail.component.ts` (used by link-expense-dialog) +- Components use inline templates (backtick strings), standalone, `@if`/`@for` control flow +- Signal-based inputs: `input()` and `input.required()` +- Signal-based outputs: `output()` +- `signal()` for reactive state, `computed()` for derived state + +### Git Intelligence + +Recent commits: +- `d764105` - Merge PR #181: Story 11.6 create work order from expense +- `40343d5` - fix(review): Address code review findings for Story 11.6 +- `edcd7d3` - feat(expenses): Add create work order from expense (Story 11.6) +- `99f71b8` - Merge PR #180: Story 11.4 view linked work order on expense +- `a044cf2` - fix(review): Address code review findings for Story 11.4 + +### Testing Standards + +**Frontend (Vitest):** +- Run with `npm test` (NEVER `npx vitest` — orphaned workers) +- Co-located `.spec.ts` files +- Mock services using `vi.fn()` and `of()` for Observable returns +- Use `TestBed.configureTestingModule()` with mock providers +- Mock `MatDialog.open()` to return `afterClosed()` observable +- Mock `MatSnackBar.open()` to verify feedback messages + +**Pattern for mocking ExpenseService:** +```typescript +const mockExpenseService = { + createExpense: vi.fn().mockReturnValue(of({ id: 'new-exp-id' })), +}; +``` + +**Pattern for mocking ExpenseStore:** +```typescript +const mockExpenseStore = { + loadCategories: vi.fn(), + sortedCategories: signal([ + { id: 'cat-1', name: 'Repairs', scheduleELine: 'Repairs', sortOrder: 1, parentId: null }, + { id: 'cat-2', name: 'Supplies', scheduleELine: 'Other', sortOrder: 2, parentId: null }, + ]), +}; +``` + +**Pattern for mocking dialog:** +```typescript +const mockDialogRef = { afterClosed: () => of({ created: true }) }; +const mockDialog = { open: vi.fn().mockReturnValue(mockDialogRef) }; +``` + +### Project Structure Notes + +- Components use inline templates and styles (backtick strings, not separate files) +- All components are `standalone: true` +- Use new control flow: `@if`, `@for`, `@else` +- Signal-based inputs: `input()` and `input.required()` +- Signal-based outputs: `output()` +- Material components imported individually +- Dialog components go in `components/` folder under the feature +- `signal()` for reactive state, `computed()` for derived state + +### References + +- [Source: epics-work-orders-vendors.md#Epic 4 Story 4.7] - Create Expense from Work Order (FR32) +- [Source: architecture.md#Decision 18] - FK on Expense (WorkOrderId), 1:N relationship +- [Source: architecture.md#API Extensions] - Work order CRUD endpoints +- [Source: work-order-detail.component.ts] - Work order detail with linked expenses section +- [Source: expense.service.ts] - createExpense() with workOrderId support +- [Source: expense.store.ts] - loadCategories(), sortedCategories +- [Source: create-wo-from-expense-dialog.component.ts] - Reverse dialog pattern (Story 11.6) +- [Source: 11-6-create-work-order-from-expense.md] - Reverse story (WO from expense) +- [Source: 11-3-link-work-order-to-expense.md] - Linked expenses section on WO detail +- [Source: 11-1-expense-workorder-relationship.md] - Backend foundation (FK, endpoints) + +### FRs Covered + +| FR | Description | How This Story Addresses | +|----|-------------|-------------------------| +| FR32 | Users can create expenses directly from work orders | "Create Expense" button on WO detail opens dialog, creates expense with workOrderId pre-set | diff --git a/frontend/src/app/features/expenses/components/create-expense-from-wo-dialog/create-expense-from-wo-dialog.component.spec.ts b/frontend/src/app/features/expenses/components/create-expense-from-wo-dialog/create-expense-from-wo-dialog.component.spec.ts new file mode 100644 index 0000000..7191131 --- /dev/null +++ b/frontend/src/app/features/expenses/components/create-expense-from-wo-dialog/create-expense-from-wo-dialog.component.spec.ts @@ -0,0 +1,282 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { signal } from '@angular/core'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { of, throwError } from 'rxjs'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { + CreateExpenseFromWoDialogComponent, + CreateExpenseFromWoDialogData, +} from './create-expense-from-wo-dialog.component'; +import { ExpenseService } from '../../services/expense.service'; +import { ExpenseStore } from '../../stores/expense.store'; + +describe('CreateExpenseFromWoDialogComponent', () => { + let component: CreateExpenseFromWoDialogComponent; + let fixture: ComponentFixture; + + const mockDialogData: CreateExpenseFromWoDialogData = { + workOrderId: 'wo-123', + propertyId: 'prop-456', + propertyName: 'Test Property', + categoryId: 'cat-1', + workOrderDescription: 'Fix the leaky faucet', + }; + + const mockExpenseService = { + createExpense: vi.fn().mockReturnValue(of({ id: 'new-exp-id' })), + }; + + const mockExpenseStore = { + loadCategories: vi.fn(), + sortedCategories: signal([ + { id: 'cat-1', name: 'Repairs', scheduleELine: 'Repairs', sortOrder: 1, parentId: null }, + { id: 'cat-2', name: 'Supplies', scheduleELine: 'Other', sortOrder: 2, parentId: null }, + ]), + }; + + const mockDialogRef = { + close: vi.fn(), + }; + + const mockSnackBar = { + open: vi.fn(), + }; + + beforeEach(async () => { + vi.clearAllMocks(); + + await TestBed.configureTestingModule({ + imports: [CreateExpenseFromWoDialogComponent], + providers: [ + provideNoopAnimations(), + { provide: MAT_DIALOG_DATA, useValue: mockDialogData }, + { provide: MatDialogRef, useValue: mockDialogRef }, + { provide: ExpenseService, useValue: mockExpenseService }, + { provide: ExpenseStore, useValue: mockExpenseStore }, + { provide: MatSnackBar, useValue: mockSnackBar }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CreateExpenseFromWoDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('dialog rendering (AC #2)', () => { + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display dialog title', () => { + const compiled = fixture.nativeElement; + expect(compiled.textContent).toContain('Create Expense for Work Order'); + }); + + it('should display property name as locked label', () => { + const compiled = fixture.nativeElement; + expect(compiled.textContent).toContain('Property: Test Property'); + }); + + it('should display work order description for context', () => { + const compiled = fixture.nativeElement; + expect(compiled.textContent).toContain('Work Order: Fix the leaky faucet'); + }); + + it('should pre-select category from work order data', () => { + expect(component.form.controls.categoryId.value).toBe('cat-1'); + }); + + it('should default date to today', () => { + const today = new Date().toISOString().split('T')[0]; + expect(component.form.controls.date.value).toBe(today); + }); + + it('should have empty amount field', () => { + expect(component.form.controls.amount.value).toBeNull(); + }); + + it('should have empty description field', () => { + expect(component.form.controls.description.value).toBe(''); + }); + + it('should load categories on init', () => { + expect(mockExpenseStore.loadCategories).toHaveBeenCalled(); + }); + + it('should display categories from store', () => { + const compiled = fixture.nativeElement; + expect(compiled.textContent).toContain('Category'); + }); + }); + + describe('validation (AC #5)', () => { + it('should mark amount as required', () => { + component.form.controls.amount.setValue(null); + component.form.controls.amount.markAsTouched(); + expect(component.form.controls.amount.hasError('required')).toBe(true); + }); + + it('should validate amount min 0.01', () => { + component.form.controls.amount.setValue(0); + component.form.controls.amount.markAsTouched(); + expect(component.form.controls.amount.hasError('min')).toBe(true); + }); + + it('should accept valid amount', () => { + component.form.controls.amount.setValue(50.00); + expect(component.form.controls.amount.valid).toBe(true); + }); + + it('should mark category as required', () => { + component.form.controls.categoryId.setValue(''); + component.form.controls.categoryId.markAsTouched(); + expect(component.form.controls.categoryId.hasError('required')).toBe(true); + }); + + it('should mark date as required', () => { + component.form.controls.date.setValue(''); + component.form.controls.date.markAsTouched(); + expect(component.form.controls.date.hasError('required')).toBe(true); + }); + + it('should have Create button disabled when form invalid', () => { + fixture.detectChanges(); + const buttons = fixture.nativeElement.querySelectorAll('button[mat-flat-button]'); + const createBtn = Array.from(buttons).find((b: any) => b.textContent.includes('Create')) as HTMLButtonElement; + expect(createBtn?.disabled).toBe(true); + }); + + it('should have Create button enabled when form valid', () => { + component.form.controls.amount.setValue(100); + component.form.controls.categoryId.setValue('cat-1'); + component.form.controls.date.setValue('2026-01-15'); + fixture.detectChanges(); + const buttons = fixture.nativeElement.querySelectorAll('button[mat-flat-button]'); + const createBtn = Array.from(buttons).find((b: any) => b.textContent.includes('Create')) as HTMLButtonElement; + expect(createBtn?.disabled).toBe(false); + }); + }); + + describe('submission (AC #3)', () => { + beforeEach(() => { + component.form.controls.amount.setValue(150.00); + component.form.controls.date.setValue('2026-01-20'); + component.form.controls.categoryId.setValue('cat-1'); + component.form.controls.description.setValue('Parts for faucet'); + }); + + it('should call expenseService.createExpense with correct data including workOrderId', () => { + component.onSubmit(); + expect(mockExpenseService.createExpense).toHaveBeenCalledWith({ + propertyId: 'prop-456', + amount: 150.00, + date: '2026-01-20', + categoryId: 'cat-1', + description: 'Parts for faucet', + workOrderId: 'wo-123', + }); + }); + + it('should show "Expense created" snackbar on success', () => { + component.onSubmit(); + expect(mockSnackBar.open).toHaveBeenCalledWith('Expense created', 'Close', { duration: 3000 }); + }); + + it('should close dialog with { created: true } on success', () => { + component.onSubmit(); + expect(mockDialogRef.close).toHaveBeenCalledWith({ created: true }); + }); + + it('should set isSubmitting during submission', () => { + expect(component.isSubmitting()).toBe(false); + component.onSubmit(); + // After successful completion, dialog closes so isSubmitting isn't reset + expect(mockDialogRef.close).toHaveBeenCalled(); + }); + + it('should not submit if form invalid', () => { + component.form.controls.amount.setValue(null); + component.onSubmit(); + expect(mockExpenseService.createExpense).not.toHaveBeenCalled(); + }); + + it('should not submit if already submitting', () => { + component.isSubmitting.set(true); + component.onSubmit(); + expect(mockExpenseService.createExpense).not.toHaveBeenCalled(); + }); + + it('should send undefined description when empty', () => { + component.form.controls.description.setValue(''); + component.onSubmit(); + expect(mockExpenseService.createExpense).toHaveBeenCalledWith( + expect.objectContaining({ description: undefined }) + ); + }); + }); + + describe('error handling (AC #6)', () => { + beforeEach(() => { + component.form.controls.amount.setValue(100); + component.form.controls.date.setValue('2026-01-20'); + component.form.controls.categoryId.setValue('cat-1'); + mockExpenseService.createExpense.mockReturnValue(throwError(() => new Error('Server error'))); + }); + + it('should show "Failed to create expense" snackbar on error', () => { + component.onSubmit(); + expect(mockSnackBar.open).toHaveBeenCalledWith('Failed to create expense', 'Close', { duration: 3000 }); + }); + + it('should keep dialog open on error (not close)', () => { + component.onSubmit(); + expect(mockDialogRef.close).not.toHaveBeenCalled(); + }); + + it('should reset isSubmitting on error for retry', () => { + component.onSubmit(); + expect(component.isSubmitting()).toBe(false); + }); + }); + + describe('cancel (AC #4)', () => { + it('should have Cancel button', () => { + const compiled = fixture.nativeElement; + expect(compiled.textContent).toContain('Cancel'); + }); + + it('should have mat-dialog-close directive on Cancel button', () => { + const cancelBtn = fixture.nativeElement.querySelector('button[mat-dialog-close]'); + expect(cancelBtn).toBeTruthy(); + expect(cancelBtn.textContent).toContain('Cancel'); + }); + }); + + describe('no categoryId in dialog data', () => { + it('should default categoryId to empty when not provided', async () => { + const dataWithoutCategory: CreateExpenseFromWoDialogData = { + ...mockDialogData, + categoryId: undefined, + }; + + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [CreateExpenseFromWoDialogComponent], + providers: [ + provideNoopAnimations(), + { provide: MAT_DIALOG_DATA, useValue: dataWithoutCategory }, + { provide: MatDialogRef, useValue: mockDialogRef }, + { provide: ExpenseService, useValue: mockExpenseService }, + { provide: ExpenseStore, useValue: mockExpenseStore }, + { provide: MatSnackBar, useValue: mockSnackBar }, + ], + }).compileComponents(); + + const f = TestBed.createComponent(CreateExpenseFromWoDialogComponent); + f.detectChanges(); + expect(f.componentInstance.form.controls.categoryId.value).toBe(''); + }); + }); +}); diff --git a/frontend/src/app/features/expenses/components/create-expense-from-wo-dialog/create-expense-from-wo-dialog.component.ts b/frontend/src/app/features/expenses/components/create-expense-from-wo-dialog/create-expense-from-wo-dialog.component.ts new file mode 100644 index 0000000..4c6ca56 --- /dev/null +++ b/frontend/src/app/features/expenses/components/create-expense-from-wo-dialog/create-expense-from-wo-dialog.component.ts @@ -0,0 +1,184 @@ +import { Component, inject, OnInit, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + FormBuilder, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogModule, +} from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatButtonModule } from '@angular/material/button'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { + ExpenseService, + CreateExpenseRequest, +} from '../../services/expense.service'; +import { ExpenseStore } from '../../stores/expense.store'; + +/** + * Data passed to the dialog (AC #2) + */ +export interface CreateExpenseFromWoDialogData { + workOrderId: string; + propertyId: string; + propertyName: string; + categoryId?: string; + workOrderDescription: string; +} + +/** + * Result returned when dialog closes with a created expense (AC #3) + */ +export interface CreateExpenseFromWoDialogResult { + created: boolean; +} + +/** + * CreateExpenseFromWoDialogComponent (Story 11.7 AC #2, #3, #4, #5, #6, #7) + * + * Dialog for creating an expense directly from a work order. + * Pre-populates property and category from the work order. + * Single API call — POST /api/v1/expenses with workOrderId set. + */ +@Component({ + selector: 'app-create-expense-from-wo-dialog', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatButtonModule, + MatProgressSpinnerModule, + ], + template: ` +

Create Expense for Work Order

+ +

Property: {{ data.propertyName }}

+

Work Order: {{ data.workOrderDescription }}

+
+ + Amount + + + @if (form.controls.amount.hasError('required')) { + Amount is required + } @else if (form.controls.amount.hasError('min')) { + Amount must be greater than 0 + } + + + + Date + + @if (form.controls.date.hasError('required')) { + Date is required + } + + + + Category + + @for (cat of categories(); track cat.id) { + {{ cat.name }} + } + + @if (form.controls.categoryId.hasError('required')) { + Category is required + } + + + + Description (optional) + + +
+
+ + + + + `, + styles: [` + .full-width { width: 100%; } + .property-label { + font-size: 0.9em; + color: var(--mat-sys-on-surface-variant); + margin-bottom: 4px; + } + .wo-context { + font-size: 0.85em; + color: var(--mat-sys-on-surface-variant); + margin-bottom: 16px; + font-style: italic; + } + mat-dialog-content { min-width: 400px; } + @media (max-width: 600px) { + mat-dialog-content { min-width: unset; } + } + `], +}) +export class CreateExpenseFromWoDialogComponent implements OnInit { + protected readonly data: CreateExpenseFromWoDialogData = inject(MAT_DIALOG_DATA); + private readonly dialogRef = inject(MatDialogRef); + private readonly fb = inject(FormBuilder); + private readonly expenseService = inject(ExpenseService); + private readonly snackBar = inject(MatSnackBar); + private readonly expenseStore = inject(ExpenseStore); + + protected readonly categories = this.expenseStore.sortedCategories; + readonly isSubmitting = signal(false); + + form = this.fb.group({ + amount: [null as number | null, [Validators.required, Validators.min(0.01)]], + date: [new Date().toISOString().split('T')[0], [Validators.required]], + categoryId: [this.data.categoryId || '', [Validators.required]], + description: [''], + }); + + ngOnInit(): void { + this.expenseStore.loadCategories(); + } + + onSubmit(): void { + if (this.form.invalid || this.isSubmitting()) return; + this.isSubmitting.set(true); + + const createRequest: CreateExpenseRequest = { + propertyId: this.data.propertyId, + amount: this.form.value.amount!, + date: this.form.value.date!, + categoryId: this.form.value.categoryId!, + description: this.form.value.description || undefined, + workOrderId: this.data.workOrderId, + }; + + this.expenseService.createExpense(createRequest).subscribe({ + next: () => { + this.snackBar.open('Expense created', 'Close', { duration: 3000 }); + this.dialogRef.close({ created: true } as CreateExpenseFromWoDialogResult); + }, + error: () => { + this.isSubmitting.set(false); + this.snackBar.open('Failed to create expense', 'Close', { duration: 3000 }); + }, + }); + } +} diff --git a/frontend/src/app/features/work-orders/pages/work-order-detail/work-order-detail.component.spec.ts b/frontend/src/app/features/work-orders/pages/work-order-detail/work-order-detail.component.spec.ts index d065be8..8e7f6b0 100644 --- a/frontend/src/app/features/work-orders/pages/work-order-detail/work-order-detail.component.spec.ts +++ b/frontend/src/app/features/work-orders/pages/work-order-detail/work-order-detail.component.spec.ts @@ -7,7 +7,6 @@ import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { By } from '@angular/platform-browser'; import { of } from 'rxjs'; -import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { WorkOrderDetailComponent } from './work-order-detail.component'; import { WorkOrderStore } from '../../stores/work-order.store'; @@ -512,6 +511,88 @@ describe('WorkOrderDetailComponent', () => { }); }); + describe('create expense from work order (Story 11.7)', () => { + it('should show "Create Expense" button in Linked Expenses section', () => { + setupWithWorkOrder(); + const compiled = fixture.nativeElement; + expect(compiled.textContent).toContain('Create Expense'); + }); + + it('should call openCreateExpenseDialog when Create Expense button clicked', () => { + setupWithWorkOrder(); + vi.spyOn(component, 'openCreateExpenseDialog'); + + const buttons = fixture.nativeElement.querySelectorAll('.expenses-actions button'); + const createBtn = Array.from(buttons).find((b: any) => b.textContent.includes('Create Expense')) as HTMLButtonElement; + expect(createBtn).toBeTruthy(); + createBtn.click(); + expect(component.openCreateExpenseDialog).toHaveBeenCalled(); + }); + + it('should open dialog with correct data from work order', () => { + setupWithWorkOrder(); + + const openSpy = vi.fn().mockReturnValue({ afterClosed: () => of(undefined) }); + (component as any).dialog = { open: openSpy }; + + component.openCreateExpenseDialog(); + + expect(openSpy).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + width: '500px', + data: expect.objectContaining({ + workOrderId: 'wo-123', + propertyId: 'prop-456', + propertyName: 'Test Property', + categoryId: 'cat-101', + workOrderDescription: 'Fix the leaky faucet in the kitchen', + }), + }) + ); + }); + + it('should refresh linked expenses after dialog closes with created: true', () => { + setupWithWorkOrder(); + + const mockWoService = TestBed.inject(WorkOrderService); + (component as any).dialog = { + open: vi.fn().mockReturnValue({ + afterClosed: () => of({ created: true }), + }), + }; + + // Reset call count after init load, but keep returning valid data + (mockWoService.getWorkOrderExpenses as ReturnType) + .mockClear() + .mockReturnValue(of({ items: [], totalCount: 0 } as WorkOrderExpensesResponse)); + + component.openCreateExpenseDialog(); + + expect(mockWoService.getWorkOrderExpenses).toHaveBeenCalledWith('wo-123'); + }); + + it('should not refresh linked expenses after dialog cancel (no result)', () => { + setupWithWorkOrder(); + + const mockWoService = TestBed.inject(WorkOrderService); + (component as any).dialog = { + open: vi.fn().mockReturnValue({ + afterClosed: () => of(undefined), + }), + }; + + // Reset call count after init load, but keep returning valid data + (mockWoService.getWorkOrderExpenses as ReturnType) + .mockClear() + .mockReturnValue(of({ items: [], totalCount: 0 } as WorkOrderExpensesResponse)); + + component.openCreateExpenseDialog(); + + expect(mockWoService.getWorkOrderExpenses).not.toHaveBeenCalled(); + }); + }); + describe('status badge colors', () => { it('should have status-reported class for Reported status', () => { const reportedWorkOrder = { ...mockWorkOrder, status: 'Reported' }; diff --git a/frontend/src/app/features/work-orders/pages/work-order-detail/work-order-detail.component.ts b/frontend/src/app/features/work-orders/pages/work-order-detail/work-order-detail.component.ts index 648e753..aa79c3b 100644 --- a/frontend/src/app/features/work-orders/pages/work-order-detail/work-order-detail.component.ts +++ b/frontend/src/app/features/work-orders/pages/work-order-detail/work-order-detail.component.ts @@ -21,6 +21,11 @@ import { WorkOrderPhotoDto } from '../../../../core/api/api.service'; import { WorkOrderService, WorkOrderExpenseItemDto } from '../../services/work-order.service'; import { ExpenseService, UpdateExpenseRequest } from '../../../expenses/services/expense.service'; import { LinkExpenseDialogComponent } from '../../components/link-expense-dialog/link-expense-dialog.component'; +import { + CreateExpenseFromWoDialogComponent, + CreateExpenseFromWoDialogData, + CreateExpenseFromWoDialogResult, +} from '../../../expenses/components/create-expense-from-wo-dialog/create-expense-from-wo-dialog.component'; /** * WorkOrderDetailComponent (Story 9-8) @@ -267,10 +272,16 @@ import { LinkExpenseDialogComponent } from '../../components/link-expense-dialog Linked Expenses - +
+ + +
@if (isLoadingExpenses()) { @@ -534,6 +545,12 @@ import { LinkExpenseDialogComponent } from '../../components/link-expense-dialog margin-right: 4px; } + .expenses-actions { + display: flex; + gap: 8px; + align-items: center; + } + .expenses-list { display: flex; flex-direction: column; @@ -749,6 +766,28 @@ export class WorkOrderDetailComponent implements OnInit, OnDestroy { }); } + openCreateExpenseDialog(): void { + const wo = this.store.selectedWorkOrder(); + if (!wo) return; + + const dialogRef = this.dialog.open(CreateExpenseFromWoDialogComponent, { + width: '500px', + data: { + workOrderId: wo.id, + propertyId: wo.propertyId, + propertyName: wo.propertyName, + categoryId: wo.categoryId, + workOrderDescription: wo.description, + } as CreateExpenseFromWoDialogData, + }); + + dialogRef.afterClosed().subscribe((result: CreateExpenseFromWoDialogResult | undefined) => { + if (result?.created) { + this.loadLinkedExpenses(this.workOrderId!); + } + }); + } + openLinkExpenseDialog(): void { const dialogRef = this.dialog.open(LinkExpenseDialogComponent, { width: '500px',