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 }}
+
+
+
+ Cancel
+
+ @if (isSubmitting()) {
+
+ } @else {
+ Create
+ }
+
+
+ ```
+
+- [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
+
+ ```
+
+- [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)
+
+
+
+
+
+ Cancel
+
+ @if (isSubmitting()) {
+
+ } @else {
+ Create
+ }
+
+
+ `,
+ 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
@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',