diff --git a/_bmad-output/implementation-artifacts/17-10-inline-status-dropdown-wo-detail.md b/_bmad-output/implementation-artifacts/17-10-inline-status-dropdown-wo-detail.md index 39aa8f89..9af7c0d8 100644 --- a/_bmad-output/implementation-artifacts/17-10-inline-status-dropdown-wo-detail.md +++ b/_bmad-output/implementation-artifacts/17-10-inline-status-dropdown-wo-detail.md @@ -1,6 +1,6 @@ # Story 17.10: Inline Status Dropdown on Work Order Detail -Status: review +Status: done ## Story @@ -282,3 +282,4 @@ Claude Opus 4.6 ## Change Log - 2026-03-04: All 6 tasks completed, all 2618 unit tests passing, Playwright visual verification done, story set to review +- 2026-03-05: Code review fixes merged (rollback bug, null guards, exhaustMap, test coverage), 2619 tests passing, story done diff --git a/_bmad-output/implementation-artifacts/17-11-wo-list-primary-photo-thumbnail.md b/_bmad-output/implementation-artifacts/17-11-wo-list-primary-photo-thumbnail.md index 985efb0d..73e8a09b 100644 --- a/_bmad-output/implementation-artifacts/17-11-wo-list-primary-photo-thumbnail.md +++ b/_bmad-output/implementation-artifacts/17-11-wo-list-primary-photo-thumbnail.md @@ -1,6 +1,6 @@ # Story 17.11: Work Order List — Primary Photo Thumbnail -Status: review +Status: done ## Story diff --git a/_bmad-output/implementation-artifacts/17-12-replace-year-selector-date-range.md b/_bmad-output/implementation-artifacts/17-12-replace-year-selector-date-range.md new file mode 100644 index 00000000..191a203b --- /dev/null +++ b/_bmad-output/implementation-artifacts/17-12-replace-year-selector-date-range.md @@ -0,0 +1,424 @@ +# Story 17.12: Replace Global Year Selector with Dashboard Date Range Filter + +Status: ready-for-dev + +## Story + +As a property owner viewing financial summaries, +I want date range filters on the dashboard and property detail pages instead of a global year selector, +so that I can see financial data for any time period and filters don't silently conflict with each other. + +**GitHub Issue:** #279 +**Effort:** L — multi-page refactor, backend dateFrom/dateTo params, frontend component integration, service + component removal + +## Acceptance Criteria + +**AC-1: Year selector removed from sidebar** +Given I view the application sidebar +When the sidebar renders +Then the year selector is no longer present + +**AC-2: Dashboard date range filter** +Given I am on the Dashboard +When I view the page +Then I see an inline `DateRangeFilterComponent` with presets: All Time, This Month, This Quarter, This Year (default), Last Year, Custom +And the summary totals (Total Expenses, Total Income, Net Income) respect the selected range + +**AC-3: Property detail date range filter** +Given I am on a Property detail page +When I view the financial summary cards +Then a local `DateRangeFilterComponent` controls Expenses, Income, Net Income totals +And labels update based on range (e.g., "YTD Expenses" for this-year, "Expenses" for custom/other ranges) + +**AC-4: Properties list date range filter** +Given I am on the Properties list +When I view per-property financial summaries +Then they respect a local date range filter (default: This Year) + +**AC-5: Income list decoupled from global year** +Given I am on the Income list with its own date range filter +When I select a date range +Then only the local date range filter applies — no global year interference +And the `yearEffect` watching `YearSelectorService` is removed + +**AC-6: Report dialogs unaffected** +Given I open a Schedule E report dialog (single or batch) +When the dialog loads +Then it still has its own year selector and works independently (no regression) +And it defaults to the current calendar year (previously from YearSelectorService) + +**AC-7: Cleanup** +Given all consumers are migrated +When migration is complete +Then `YearSelectorService`, `YearSelectorComponent`, and localStorage key `propertyManager.selectedYear` are removed +And no references remain anywhere in the codebase + +## Tasks / Subtasks + +### Task 1: Add "Last Year" preset to DateRangeFilterComponent (AC: 2) + +- [ ] 1.1: Add `'last-year'` to the `DateRangePreset` union type in `frontend/src/app/shared/utils/date-range.utils.ts` +- [ ] 1.2: Add `case 'last-year'` to `getDateRangeFromPreset()` — return Jan 1 to Dec 31 of `currentYear - 1` +- [ ] 1.3: Add `Last Year` to `DateRangeFilterComponent` template (between "This Year" and "Custom Range") +- [ ] 1.4: Add unit tests for the new preset in `date-range.utils.spec.ts` (if exists) and `date-range-filter.component.spec.ts` + +### Task 2: Backend — Add dateFrom/dateTo to property and dashboard endpoints (AC: 2, 3, 4) + +- [ ] 2.1: Update `GetAllPropertiesQuery` record to add `DateOnly? DateFrom = null, DateOnly? DateTo = null` parameters +- [ ] 2.2: Update `GetAllPropertiesQueryHandler.Handle()` — when `DateFrom`/`DateTo` are provided, use them for date range filtering instead of `year`. Keep existing `Year` fallback for backward compat +- [ ] 2.3: Update `GetPropertyByIdQuery` record to add `DateOnly? DateFrom = null, DateOnly? DateTo = null` parameters +- [ ] 2.4: Update `GetPropertyByIdQueryHandler.Handle()` — when `DateFrom`/`DateTo` are provided, use `request.DateFrom`/`request.DateTo` instead of computing `yearStart`/`yearEnd` from year +- [ ] 2.5: Update `GetDashboardTotalsQuery` record to add `DateOnly? DateFrom = null, DateOnly? DateTo = null` parameters (keep `Year` for backward compat) +- [ ] 2.6: Update `GetDashboardTotalsQueryHandler.Handle()` — when `DateFrom`/`DateTo` are provided, use them; otherwise fall back to existing `Year`-based filtering +- [ ] 2.7: Update `PropertiesController.GetAllProperties()` — add `[FromQuery] DateOnly? dateFrom = null, [FromQuery] DateOnly? dateTo = null` params, pass to query +- [ ] 2.8: Update `PropertiesController.GetPropertyById()` (read the method — it has a `[FromQuery] int? year` param) — add `dateFrom`/`dateTo` params, pass to query +- [ ] 2.9: Update `DashboardController.GetTotals()` — add `dateFrom`/`dateTo` params, pass to query. Change `year` from required to optional (default to current year when nothing is provided) +- [ ] 2.10: Add backend unit tests for dateFrom/dateTo filtering in all three handlers +- [ ] 2.11: Regenerate NSwag API client: run `npm run generate-api` from `/frontend` + +### Task 3: Frontend — Update PropertyService and PropertyStore for date range (AC: 2, 3, 4) + +- [ ] 3.1: Update `PropertyService.getProperties()` signature to accept `params?: { year?: number; dateFrom?: string; dateTo?: string }` instead of `year?: number`. Build query params from the object +- [ ] 3.2: Update `PropertyService.getPropertyById()` signature to accept `id: string, params?: { year?: number; dateFrom?: string; dateTo?: string }` instead of `id: string, year?: number` +- [ ] 3.3: Update `PropertyStore.loadProperties` rxMethod — change input type from `number | undefined` to `{ dateFrom?: string; dateTo?: string } | undefined`. Update `switchMap` to call `propertyService.getProperties(params)` +- [ ] 3.4: Update `PropertyStore.loadPropertyById` rxMethod — change input type from `{ id: string; year?: number }` to `{ id: string; dateFrom?: string; dateTo?: string }`. Update `switchMap` accordingly +- [ ] 3.5: Replace `selectedYear: number | null` in `PropertyState` with `dateFrom: string | null` and `dateTo: string | null`. Update `initialState` accordingly +- [ ] 3.6: Update property store unit tests for new parameter shapes + +### Task 4: Dashboard — Add DateRangeFilterComponent, remove year dependency (AC: 2) + +- [ ] 4.1: Import `DateRangeFilterComponent` in DashboardComponent imports array +- [ ] 4.2: Add date range state signals: `dateRangePreset = signal('this-year')`, `dateFrom = signal(null)`, `dateTo = signal(null)`. Compute initial values from `getDateRangeFromPreset('this-year')` +- [ ] 4.3: Add `` to dashboard template — place between the header and the ``, inside a `` wrapper for visual consistency with other pages +- [ ] 4.4: Add `onDateRangePresetChange()` and `onCustomDateRangeChange()` handler methods — update signals, recalculate dateFrom/dateTo via `getDateRangeFromPreset()`, call `loadProperties()` +- [ ] 4.5: Replace the existing `effect()` (lines 192-195) that watches `yearService.selectedYear()` with a new `effect()` that watches `dateFrom()`/`dateTo()` and calls `propertyStore.loadProperties({ dateFrom, dateTo })` +- [ ] 4.6: Remove `YearSelectorService` import and injection +- [ ] 4.7: Update `loadProperties()` method to use date range signals instead of `yearService.selectedYear()` +- [ ] 4.8: Import `getDateRangeFromPreset`, `DateRangePreset` from shared utils +- [ ] 4.9: Add dashboard component unit tests for date range filter integration + +### Task 5: Properties list — Add DateRangeFilterComponent (AC: 4) + +- [ ] 5.1: Import `DateRangeFilterComponent` and `MatCardModule` in PropertiesComponent imports +- [ ] 5.2: Add date range state signals (same pattern as Task 4.2, default `'this-year'`) +- [ ] 5.3: Add `` to properties template — place below the page header, inside a `` +- [ ] 5.4: Add `onDateRangePresetChange()` and `onCustomDateRangeChange()` handler methods +- [ ] 5.5: Replace the existing `effect()` (lines 162-165) that watches `yearService.selectedYear()` with date range effect +- [ ] 5.6: Remove `YearSelectorService` import and injection +- [ ] 5.7: Update `loadProperties()` method to use date range signals +- [ ] 5.8: Add properties component unit tests for date range filter integration + +### Task 6: Property detail — Add DateRangeFilterComponent (AC: 3) + +- [ ] 6.1: Import `DateRangeFilterComponent` and `MatCardModule` in PropertyDetailComponent imports +- [ ] 6.2: Add date range state signals (same pattern, default `'this-year'`) +- [ ] 6.3: Add `` to template — place above the `.stats-section` (before line 177), inside a compact filter card +- [ ] 6.4: Add handler methods for preset and custom date range changes +- [ ] 6.5: Replace the existing `effect()` (lines 676-681) that watches `yearService.selectedYear()` with date range effect that calls `propertyStore.loadPropertyById({ id: this.propertyId, dateFrom, dateTo })` +- [ ] 6.6: Update stat card labels to be dynamic: use `'YTD Expenses'` when preset is `'this-year'`, otherwise `'Expenses'` (same for Income). Use a computed signal: `expenseLabel = computed(() => this.dateRangePreset() === 'this-year' ? 'YTD Expenses' : 'Expenses')` +- [ ] 6.7: Remove `YearSelectorService` import and injection +- [ ] 6.8: Update the `ReportDialogComponent` data passing (if property-detail passes `currentYear: this.yearService.selectedYear()` to it) — replace with `currentYear: new Date().getFullYear()` +- [ ] 6.9: Add property detail unit tests for date range filter integration and dynamic labels + +### Task 7: Income — Decouple from global year selector (AC: 5) + +- [ ] 7.1: Remove the `yearEffect` field (lines 496-499) from `IncomeComponent` +- [ ] 7.2: Remove `YearSelectorService` import and injection (line 28, 481) +- [ ] 7.3: Remove `year` state from `IncomeListStore` — remove `year: number | null` from state interface, remove from initialState, remove `setYear()` method +- [ ] 7.4: Update `IncomeListStore.currentFilters` computed — remove `year: store.year() ?? undefined` from the returned object. When preset is not custom, `getDateRangeFromPreset()` no longer needs a year param since the function uses `today.getFullYear()` by default +- [ ] 7.5: Update `IncomeListStore.setDateRangePreset()` — remove `store.year()` from `getDateRangeFromPreset()` call (just pass preset) +- [ ] 7.6: Update income component and store unit tests — remove YearSelectorService mocking, remove year-related test cases + +### Task 8: Report dialogs — Decouple from YearSelectorService (AC: 6) + +- [ ] 8.1: In `BatchReportDialogComponent` — change `selectedYear = this.yearService.selectedYear()` to `selectedYear = new Date().getFullYear()`. Remove `YearSelectorService` import and injection +- [ ] 8.2: Verify batch report dialog still has its own year dropdown with `generateYearOptions()` — no changes needed to that +- [ ] 8.3: Check `ReportDialogComponent` (single property report) — if it injects `YearSelectorService`, decouple it the same way. If it receives year via dialog data from property-detail, Task 6.8 already handles it +- [ ] 8.4: Update report dialog unit tests — remove YearSelectorService mocking + +### Task 9: Cleanup — Remove YearSelectorService and YearSelectorComponent (AC: 1, 7) + +- [ ] 9.1: Remove `` from `sidebar-nav.component.html` (lines 7-10, the `.year-selector-container` div) +- [ ] 9.2: Remove `` from `shell.component.html` tablet header (line 31) +- [ ] 9.3: Remove `` from `shell.component.html` mobile header (line 55) +- [ ] 9.4: Remove `YearSelectorComponent` from `SidebarNavComponent` imports array +- [ ] 9.5: Remove `YearSelectorComponent` from `ShellComponent` imports array and its import statement +- [ ] 9.6: Delete file: `frontend/src/app/core/services/year-selector.service.ts` +- [ ] 9.7: Delete file: `frontend/src/app/core/services/year-selector.service.spec.ts` +- [ ] 9.8: Delete file: `frontend/src/app/shared/components/year-selector/year-selector.component.ts` +- [ ] 9.9: Delete file: `frontend/src/app/shared/components/year-selector/year-selector.component.spec.ts` +- [ ] 9.10: Verify no remaining references — search codebase for `YearSelectorService`, `YearSelectorComponent`, `year-selector`, `propertyManager.selectedYear` +- [ ] 9.11: Remove any sidebar SCSS for `.year-selector-container` if present in sidebar component styles + +### Task 10: Final validation (AC: all) + +- [ ] 10.1: Run all frontend unit tests: `npm test` from `/frontend` — expect zero regressions +- [ ] 10.2: Run all backend unit tests: `dotnet test` from `/backend` +- [ ] 10.3: Manual smoke test: dashboard loads with "This Year" default, changing filter updates totals +- [ ] 10.4: Manual smoke test: properties list loads with date range filter, per-property totals update +- [ ] 10.5: Manual smoke test: property detail shows date range filter, stat cards update, labels change for non-YTD ranges +- [ ] 10.6: Manual smoke test: income list works without global year interference +- [ ] 10.7: Manual smoke test: batch report dialog opens, defaults to current year, generates correctly +- [ ] 10.8: Manual smoke test: sidebar has no year selector on desktop, tablet, and mobile + +## Dev Notes + +### Architecture Overview + +This story replaces a **global year selector** (sidebar widget → singleton service → effects in 4 components) with **local date range filters** per page. The existing `DateRangeFilterComponent` is already used by Expenses and Income lists — this story reuses it on Dashboard, Properties list, and Property detail. + +**Current data flow (REMOVE):** +``` +YearSelectorComponent (sidebar/toolbar) + → YearSelectorService (singleton, localStorage) + → effect() in DashboardComponent → propertyStore.loadProperties(year) + → effect() in PropertiesComponent → propertyStore.loadProperties(year) + → effect() in PropertyDetailComponent → propertyStore.loadPropertyById({id, year}) + → effect() in IncomeComponent → incomeStore.setYear(year) + → BatchReportDialogComponent → initializes selectedYear from service +``` + +**New data flow (ADD):** +``` +DateRangeFilterComponent (per page, local state) + → Page component state (dateRangePreset, dateFrom, dateTo signals) + → effect() → propertyStore.loadProperties({ dateFrom, dateTo }) + → effect() → propertyStore.loadPropertyById({ id, dateFrom, dateTo }) +``` + +### Existing DateRangeFilterComponent — Ready for Reuse + +**File:** `frontend/src/app/shared/components/date-range-filter/date-range-filter.component.ts` + +Standalone presentation component. No state — receives values via inputs, emits via outputs: +```typescript +// Inputs +dateRangePreset = input('all'); +dateFrom = input(null); +dateTo = input(null); + +// Outputs +dateRangePresetChange = output(); +customDateRangeChange = output<{ dateFrom: string; dateTo: string }>(); +``` + +**Current presets:** `'all' | 'this-month' | 'this-quarter' | 'this-year' | 'custom'` +**New preset to add:** `'last-year'` + +Utility function: `getDateRangeFromPreset(preset, year?)` in `shared/utils/date-range.utils.ts` — computes `{ dateFrom, dateTo }` strings from presets. + +### Backend Changes — dateFrom/dateTo Parameters + +All three backend handlers already use `DateOnly` internally for year-based filtering. Adding `dateFrom`/`dateTo` query params is straightforward. + +**Pattern (existing in GetPropertyByIdQueryHandler):** +```csharp +var yearStart = new DateOnly(year, 1, 1); +var yearEnd = new DateOnly(year, 12, 31); +``` + +**New pattern:** +```csharp +var dateStart = request.DateFrom ?? new DateOnly(year, 1, 1); +var dateEnd = request.DateTo ?? new DateOnly(year, 12, 31); +``` + +When `DateFrom`/`DateTo` are provided, use them directly. When absent, fall back to year (existing behavior). This ensures backward compatibility for any direct API consumers. + +**GetAllPropertiesQueryHandler** uses `e.Date.Year == year` instead of date range — change to `e.Date >= dateStart && e.Date <= dateEnd` for consistency with the other handlers. + +**DashboardController.GetTotals** currently requires `year` — make it optional with fallback to `DateTime.UtcNow.Year`. + +ASP.NET Core binds `DateOnly` from query strings natively (format: `YYYY-MM-DD`). No custom model binder needed. + +### Frontend PropertyService — Updated Signatures + +**Current:** +```typescript +getProperties(year?: number): Observable +getPropertyById(id: string, year?: number): Observable +``` + +**New:** +```typescript +getProperties(params?: { year?: number; dateFrom?: string; dateTo?: string }): Observable +getPropertyById(id: string, params?: { year?: number; dateFrom?: string; dateTo?: string }): Observable +``` + +Build HttpParams from the object — only include keys that have values. + +### Frontend PropertyStore — Updated rxMethod Signatures + +**Current:** +```typescript +loadProperties: rxMethod(...) +loadPropertyById: rxMethod<{ id: string; year?: number }>(...) +``` + +**New:** +```typescript +loadProperties: rxMethod<{ dateFrom?: string; dateTo?: string } | undefined>(...) +loadPropertyById: rxMethod<{ id: string; dateFrom?: string; dateTo?: string }>(...) +``` + +Replace `selectedYear: number | null` in PropertyState with `dateFrom: string | null; dateTo: string | null`. + +### Dashboard Integration Pattern + +Follow Income component's pattern for DateRangeFilterComponent integration. Key difference: dashboard defaults to `'this-year'` preset instead of `'all'`. + +```typescript +// State +dateRangePreset = signal('this-year'); +dateFrom = signal(null); +dateTo = signal(null); + +constructor() { + // Initialize date range from default preset + const initial = getDateRangeFromPreset('this-year'); + this.dateFrom.set(initial.dateFrom); + this.dateTo.set(initial.dateTo); + + // React to date range changes + effect(() => { + const from = this.dateFrom(); + const to = this.dateTo(); + this.propertyStore.loadProperties({ dateFrom: from ?? undefined, dateTo: to ?? undefined }); + }); +} + +onDateRangePresetChange(preset: DateRangePreset): void { + this.dateRangePreset.set(preset); + const { dateFrom, dateTo } = getDateRangeFromPreset(preset); + this.dateFrom.set(dateFrom); + this.dateTo.set(dateTo); +} + +onCustomDateRangeChange(range: { dateFrom: string; dateTo: string }): void { + this.dateRangePreset.set('custom'); + this.dateFrom.set(range.dateFrom); + this.dateTo.set(range.dateTo); +} +``` + +### Property Detail — Dynamic Stat Labels + +Current hardcoded labels: `"YTD Expenses"`, `"YTD Income"`, `"Net Income"`. + +Replace with computed signals: +```typescript +expenseLabel = computed(() => this.dateRangePreset() === 'this-year' ? 'YTD Expenses' : 'Expenses'); +incomeLabel = computed(() => this.dateRangePreset() === 'this-year' ? 'YTD Income' : 'Income'); +``` + +### Income Store — Year Removal + +The `IncomeListStore` has a `year: number | null` state field that is set by the `yearEffect` in `IncomeComponent`. After removing the year effect: +- Remove `year` from state interface and initial state +- Remove `setYear()` method +- In `currentFilters` computed, call `getDateRangeFromPreset(store.dateRangePreset())` without passing year — the function already uses `today.getFullYear()` as default +- In `setDateRangePreset()`, call `getDateRangeFromPreset(preset)` without year + +### Report Dialogs — Minimal Changes + +`BatchReportDialogComponent` (line 270): `selectedYear = this.yearService.selectedYear()` → `selectedYear = new Date().getFullYear()`. It already has its own year dropdown via `generateYearOptions()`. + +Check `ReportDialogComponent` — if it receives year via `@Inject(MAT_DIALOG_DATA)` from property-detail, update the caller (Task 6.8) to pass `new Date().getFullYear()`. + +### Execution Order + +Tasks should be executed in order (1→2→3→4→5→6→7→8→9→10) since: +- Task 1 (preset) is needed by Tasks 4-6 +- Task 2 (backend) is needed by Task 3 +- Task 3 (store/service) is needed by Tasks 4-6 +- Tasks 4-8 can be done in any order +- Task 9 (cleanup) must be last before validation + +### Files to Modify + +**Frontend (modify):** +- `frontend/src/app/shared/utils/date-range.utils.ts` — add `'last-year'` preset +- `frontend/src/app/shared/components/date-range-filter/date-range-filter.component.ts` — add mat-option +- `frontend/src/app/features/properties/services/property.service.ts` — update method signatures +- `frontend/src/app/features/properties/stores/property.store.ts` — update rxMethod types, state +- `frontend/src/app/features/dashboard/dashboard.component.ts` — add filter, remove year service +- `frontend/src/app/features/properties/properties.component.ts` — add filter, remove year service +- `frontend/src/app/features/properties/property-detail/property-detail.component.ts` — add filter, dynamic labels +- `frontend/src/app/features/income/income.component.ts` — remove yearEffect, year service +- `frontend/src/app/features/income/stores/income-list.store.ts` — remove year state +- `frontend/src/app/features/reports/components/batch-report-dialog/batch-report-dialog.component.ts` — decouple +- `frontend/src/app/core/components/sidebar-nav/sidebar-nav.component.html` — remove year selector +- `frontend/src/app/core/components/sidebar-nav/sidebar-nav.component.ts` — remove import +- `frontend/src/app/core/components/shell/shell.component.html` — remove year selector (2 instances) +- `frontend/src/app/core/components/shell/shell.component.ts` — remove import + +**Frontend (delete):** +- `frontend/src/app/core/services/year-selector.service.ts` +- `frontend/src/app/core/services/year-selector.service.spec.ts` +- `frontend/src/app/shared/components/year-selector/year-selector.component.ts` +- `frontend/src/app/shared/components/year-selector/year-selector.component.spec.ts` + +**Frontend test files (modify):** +- `frontend/src/app/shared/components/date-range-filter/date-range-filter.component.spec.ts` — add last-year test +- `frontend/src/app/features/dashboard/dashboard.component.spec.ts` — remove year service mock, add filter tests +- `frontend/src/app/features/properties/properties.component.spec.ts` — same +- `frontend/src/app/features/properties/property-detail/property-detail.component.spec.ts` — same +- `frontend/src/app/features/income/income.component.spec.ts` — remove year service mock +- `frontend/src/app/features/income/stores/income-list.store.spec.ts` — remove year tests +- `frontend/src/app/features/properties/stores/property.store.spec.ts` — update for new param types +- `frontend/src/app/features/reports/components/batch-report-dialog/batch-report-dialog.component.spec.ts` — remove year service mock + +**Backend (modify):** +- `backend/src/PropertyManager.Application/Properties/GetAllProperties.cs` — add DateFrom/DateTo to query + handler +- `backend/src/PropertyManager.Application/Properties/GetPropertyById.cs` — add DateFrom/DateTo to query + handler +- `backend/src/PropertyManager.Application/Dashboard/GetDashboardTotals.cs` — add DateFrom/DateTo, make Year optional +- `backend/src/PropertyManager.Api/Controllers/PropertiesController.cs` — add query params +- `backend/src/PropertyManager.Api/Controllers/DashboardController.cs` — add query params, make year optional + +**Backend test files (modify/create):** +- `backend/tests/PropertyManager.Application.Tests/Properties/GetAllPropertiesHandlerTests.cs` — add dateFrom/dateTo tests +- `backend/tests/PropertyManager.Application.Tests/Properties/GetPropertyByIdHandlerTests.cs` — add dateFrom/dateTo tests +- `backend/tests/PropertyManager.Application.Tests/Dashboard/GetDashboardTotalsHandlerTests.cs` — add dateFrom/dateTo tests + +### Project Structure Notes + +- Aligned with Clean Architecture: backend query changes in Application layer, controller param changes in Api layer +- Frontend follows feature-based structure: stores, services, components all within their feature folders +- Shared DateRangeFilterComponent stays in `shared/components/` — no new shared components needed +- No new files created (except possibly test files if they don't exist) + +### References + +- [Source: `frontend/src/app/core/services/year-selector.service.ts` — full YearSelectorService implementation to be removed] +- [Source: `frontend/src/app/shared/components/year-selector/year-selector.component.ts` — full YearSelectorComponent to be removed] +- [Source: `frontend/src/app/shared/components/date-range-filter/date-range-filter.component.ts` — reusable DateRangeFilterComponent with inputs/outputs] +- [Source: `frontend/src/app/shared/utils/date-range.utils.ts` — DateRangePreset type and getDateRangeFromPreset utility] +- [Source: `frontend/src/app/features/dashboard/dashboard.component.ts` — lines 186-195 (yearService injection, effect)] +- [Source: `frontend/src/app/features/properties/properties.component.ts` — lines 157-165 (yearService injection, effect)] +- [Source: `frontend/src/app/features/properties/property-detail/property-detail.component.ts` — lines 647, 674-681 (yearService injection, effect)] +- [Source: `frontend/src/app/features/income/income.component.ts` — lines 481, 496-499 (yearService, yearEffect)] +- [Source: `frontend/src/app/features/income/stores/income-list.store.ts` — lines 45, 155-166, 248-251 (year state, currentFilters, setYear)] +- [Source: `frontend/src/app/features/reports/components/batch-report-dialog/batch-report-dialog.component.ts` — line 266, 270 (yearService injection, selectedYear init)] +- [Source: `frontend/src/app/features/properties/stores/property.store.ts` — lines 27, 140-168, 195-226 (selectedYear state, loadProperties, loadPropertyById)] +- [Source: `frontend/src/app/features/properties/services/property.service.ts` — lines 78-91 (getProperties, getPropertyById with year param)] +- [Source: `frontend/src/app/core/components/shell/shell.component.html` — lines 31, 55 (year selector in tablet/mobile headers)] +- [Source: `frontend/src/app/core/components/sidebar-nav/sidebar-nav.component.html` — lines 7-10 (year selector in sidebar)] +- [Source: `backend/src/PropertyManager.Application/Properties/GetAllProperties.cs` — line 11 (Year param), line 76 (e.Date.Year == year filtering)] +- [Source: `backend/src/PropertyManager.Application/Properties/GetPropertyById.cs` — lines 13, 78-80 (Year param, yearStart/yearEnd)] +- [Source: `backend/src/PropertyManager.Application/Dashboard/GetDashboardTotals.cs` — lines 11, 42-43 (Year param, yearStart/yearEnd)] +- [Source: `backend/src/PropertyManager.Api/Controllers/PropertiesController.cs` — line 47 (year query param)] +- [Source: `backend/src/PropertyManager.Api/Controllers/DashboardController.cs` — line 40 (year query param, required)] +- [Source: project-context.md — Clean Architecture patterns, Angular signals patterns, testing rules] +- [Source: GitHub Issue #279 — Replace global year selector with date range filter] + +## Dev Agent Record + +### Agent Model Used + +{{agent_model_name_version}} + +### Debug Log References + +### Completion Notes List + +### File List diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index ad1f8acb..69ada870 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -267,7 +267,7 @@ development_status: 17-8-full-size-add-vendor-form: done # Issue #274 - M — PR #291 merged 17-9-photo-upload-multi-file-support: done # Issue #276 - M — PR #293 merged 17-10-inline-status-dropdown-wo-detail: done # Issue #277 - M — PR #294 merged - 17-11-wo-list-primary-photo-thumbnail: review # Issue #270 - M - 17-12-replace-year-selector-date-range: pending # Issue #279 - L + 17-11-wo-list-primary-photo-thumbnail: done # Issue #270 - M — PR #295 merged + 17-12-replace-year-selector-date-range: ready-for-dev # Issue #279 - L 17-13-vendor-photo-support: pending # Issue #271 - L epic-17-retrospective: optional diff --git a/backend/src/PropertyManager.Api/Controllers/DashboardController.cs b/backend/src/PropertyManager.Api/Controllers/DashboardController.cs index 0a86ef1b..cf3536f1 100644 --- a/backend/src/PropertyManager.Api/Controllers/DashboardController.cs +++ b/backend/src/PropertyManager.Api/Controllers/DashboardController.cs @@ -30,16 +30,18 @@ public DashboardController( /// Get dashboard totals for the specified tax year (AC-4.4.1, AC-4.4.2, AC-4.4.6). /// Returns aggregated expenses, income, net income, and property count. /// - /// Tax year to aggregate totals for (required) + /// Optional tax year to aggregate totals for (defaults to current year) + /// Optional start date filter (YYYY-MM-DD) + /// Optional end date filter (YYYY-MM-DD) /// Dashboard totals including expenses, income, net income, and property count /// Returns the dashboard totals /// If user is not authenticated [HttpGet("totals")] [ProducesResponseType(typeof(DashboardTotalsDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] - public async Task GetTotals([FromQuery] int year) + public async Task GetTotals([FromQuery] int? year = null, [FromQuery] DateOnly? dateFrom = null, [FromQuery] DateOnly? dateTo = null) { - var query = new GetDashboardTotalsQuery(year); + var query = new GetDashboardTotalsQuery(year, dateFrom, dateTo); var response = await _mediator.Send(query); _logger.LogInformation( diff --git a/backend/src/PropertyManager.Api/Controllers/PropertiesController.cs b/backend/src/PropertyManager.Api/Controllers/PropertiesController.cs index 0cd890c3..6f4703de 100644 --- a/backend/src/PropertyManager.Api/Controllers/PropertiesController.cs +++ b/backend/src/PropertyManager.Api/Controllers/PropertiesController.cs @@ -38,15 +38,17 @@ public PropertiesController( /// Get all properties for the current user (AC-2.1.4, AC-2.2.6). /// /// Optional tax year filter for expense/income totals + /// Optional start date filter (YYYY-MM-DD) + /// Optional end date filter (YYYY-MM-DD) /// List of properties with summary information /// Returns the list of properties /// If user is not authenticated [HttpGet] [ProducesResponseType(typeof(GetAllPropertiesResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] - public async Task GetAllProperties([FromQuery] int? year = null) + public async Task GetAllProperties([FromQuery] int? year = null, [FromQuery] DateOnly? dateFrom = null, [FromQuery] DateOnly? dateTo = null) { - var query = new GetAllPropertiesQuery(year); + var query = new GetAllPropertiesQuery(year, dateFrom, dateTo); var response = await _mediator.Send(query); _logger.LogInformation( @@ -63,6 +65,8 @@ public async Task GetAllProperties([FromQuery] int? year = null) /// /// Property GUID /// Optional tax year filter for expense totals (defaults to current year) + /// Optional start date filter (YYYY-MM-DD) + /// Optional end date filter (YYYY-MM-DD) /// Property detail information /// Returns the property detail /// If user is not authenticated @@ -71,9 +75,9 @@ public async Task GetAllProperties([FromQuery] int? year = null) [ProducesResponseType(typeof(PropertyDetailDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] - public async Task GetPropertyById(Guid id, [FromQuery] int? year = null) + public async Task GetPropertyById(Guid id, [FromQuery] int? year = null, [FromQuery] DateOnly? dateFrom = null, [FromQuery] DateOnly? dateTo = null) { - var query = new GetPropertyByIdQuery(id, year); + var query = new GetPropertyByIdQuery(id, year, dateFrom, dateTo); var property = await _mediator.Send(query); if (property == null) diff --git a/backend/src/PropertyManager.Application/Dashboard/GetDashboardTotals.cs b/backend/src/PropertyManager.Application/Dashboard/GetDashboardTotals.cs index 1bce2163..b4b0a0d1 100644 --- a/backend/src/PropertyManager.Application/Dashboard/GetDashboardTotals.cs +++ b/backend/src/PropertyManager.Application/Dashboard/GetDashboardTotals.cs @@ -8,7 +8,7 @@ namespace PropertyManager.Application.Dashboard; /// Query to get dashboard totals for the current user's account (AC-4.4.1, AC-4.4.2). /// /// Tax year to aggregate totals for -public record GetDashboardTotalsQuery(int Year) : IRequest; +public record GetDashboardTotalsQuery(int? Year = null, DateOnly? DateFrom = null, DateOnly? DateTo = null) : IRequest; /// /// Dashboard totals DTO containing aggregated financial data. @@ -39,8 +39,9 @@ public GetDashboardTotalsQueryHandler( public async Task Handle(GetDashboardTotalsQuery request, CancellationToken cancellationToken) { - var yearStart = new DateOnly(request.Year, 1, 1); - var yearEnd = new DateOnly(request.Year, 12, 31); + var year = request.Year ?? DateTime.UtcNow.Year; + var yearStart = request.DateFrom ?? new DateOnly(year, 1, 1); + var yearEnd = request.DateTo ?? new DateOnly(year, 12, 31); // Get total expenses for the year var totalExpenses = await _dbContext.Expenses diff --git a/backend/src/PropertyManager.Application/Properties/GetAllProperties.cs b/backend/src/PropertyManager.Application/Properties/GetAllProperties.cs index 5b3f4ee2..e9aa6ade 100644 --- a/backend/src/PropertyManager.Application/Properties/GetAllProperties.cs +++ b/backend/src/PropertyManager.Application/Properties/GetAllProperties.cs @@ -8,7 +8,7 @@ namespace PropertyManager.Application.Properties; /// Query to get all properties for the current user's account. /// /// Optional tax year filter for expense/income totals (defaults to current year) -public record GetAllPropertiesQuery(int? Year = null) : IRequest; +public record GetAllPropertiesQuery(int? Year = null, DateOnly? DateFrom = null, DateOnly? DateTo = null) : IRequest; /// /// Response containing list of properties. @@ -56,6 +56,8 @@ public GetAllPropertiesQueryHandler( public async Task Handle(GetAllPropertiesQuery request, CancellationToken cancellationToken) { var year = request.Year ?? DateTime.UtcNow.Year; + var dateStart = request.DateFrom ?? new DateOnly(year, 1, 1); + var dateEnd = request.DateTo ?? new DateOnly(year, 12, 31); // Query properties with primary photo thumbnail storage key var propertiesData = await _dbContext.Properties @@ -73,13 +75,13 @@ public async Task Handle(GetAllPropertiesQuery request .Where(e => e.PropertyId == p.Id && e.AccountId == _currentUser.AccountId && e.DeletedAt == null - && e.Date.Year == year) + && e.Date >= dateStart && e.Date <= dateEnd) .Sum(e => (decimal?)e.Amount) ?? 0m, IncomeTotal = _dbContext.Income .Where(i => i.PropertyId == p.Id && i.AccountId == _currentUser.AccountId && i.DeletedAt == null - && i.Date.Year == year) + && i.Date >= dateStart && i.Date <= dateEnd) .Sum(i => (decimal?)i.Amount) ?? 0m, PrimaryPhotoThumbnailStorageKey = _dbContext.PropertyPhotos .Where(pp => pp.PropertyId == p.Id && pp.IsPrimary) diff --git a/backend/src/PropertyManager.Application/Properties/GetPropertyById.cs b/backend/src/PropertyManager.Application/Properties/GetPropertyById.cs index 10752f2c..0d67e3c5 100644 --- a/backend/src/PropertyManager.Application/Properties/GetPropertyById.cs +++ b/backend/src/PropertyManager.Application/Properties/GetPropertyById.cs @@ -10,7 +10,7 @@ namespace PropertyManager.Application.Properties; /// /// Property GUID /// Optional tax year filter (defaults to current year) (AC-3.5.6) -public record GetPropertyByIdQuery(Guid Id, int? Year = null) : IRequest; +public record GetPropertyByIdQuery(Guid Id, int? Year = null, DateOnly? DateFrom = null, DateOnly? DateTo = null) : IRequest; /// /// Detail DTO for property view page (AC-2.3.2, AC-13.3a.9). @@ -74,10 +74,10 @@ public GetPropertyByIdQueryHandler( public async Task Handle(GetPropertyByIdQuery request, CancellationToken cancellationToken) { - // Use provided year or default to current year (AC-3.5.6) + // Use provided date range, or fall back to year-based range (AC-3.5.6) var year = request.Year ?? DateTime.UtcNow.Year; - var yearStart = new DateOnly(year, 1, 1); - var yearEnd = new DateOnly(year, 12, 31); + var yearStart = request.DateFrom ?? new DateOnly(year, 1, 1); + var yearEnd = request.DateTo ?? new DateOnly(year, 12, 31); var propertyData = await _dbContext.Properties .Where(p => p.Id == request.Id && p.AccountId == _currentUser.AccountId && p.DeletedAt == null) diff --git a/frontend/e2e/tests/income/income-shared-components.spec.ts b/frontend/e2e/tests/income/income-shared-components.spec.ts index ae5c1a77..7eb31f76 100644 --- a/frontend/e2e/tests/income/income-shared-components.spec.ts +++ b/frontend/e2e/tests/income/income-shared-components.spec.ts @@ -41,7 +41,7 @@ test.describe('Story 16.6 — Income Page Shared Components', () => { await expect(presetDropdown).toBeVisible(); }); - test('should show 5 preset options when dropdown opened', async ({ + test('should show 6 preset options when dropdown opened', async ({ page, authenticatedUser, }) => { @@ -58,9 +58,9 @@ test.describe('Story 16.6 — Income Page Shared Components', () => { // WHEN: Opening the preset dropdown await page.locator('app-date-range-filter mat-select').click(); - // THEN: All 5 preset options are available + // THEN: All 6 preset options are available (All Time, This Month, This Quarter, This Year, Last Year, Custom Range) const options = page.locator('mat-option'); - await expect(options).toHaveCount(5); + await expect(options).toHaveCount(6); }); test('should show custom date inputs when Custom Range selected', async ({ diff --git a/frontend/src/app/core/components/shell/shell.component.html b/frontend/src/app/core/components/shell/shell.component.html index 12d05cf0..ff218ff9 100644 --- a/frontend/src/app/core/components/shell/shell.component.html +++ b/frontend/src/app/core/components/shell/shell.component.html @@ -28,7 +28,6 @@ - {{ userDisplayName }} + + + + + ('this-year'); + private readonly dateRange = signal(getDateRangeFromPreset('this-year')); + readonly dateFrom = computed(() => this.dateRange().dateFrom); + readonly dateTo = computed(() => this.dateRange().dateTo); + constructor() { - // React to year changes and reload properties (AC-3.5.3) effect(() => { - const year = this.yearService.selectedYear(); - this.propertyStore.loadProperties(year); + const { dateFrom, dateTo } = this.dateRange(); + this.propertyStore.loadProperties({ dateFrom: dateFrom ?? undefined, dateTo: dateTo ?? undefined }); }); } - ngOnInit(): void { - // Initial load happens via effect when selectedYear signal is read + loadProperties(): void { + const { dateFrom, dateTo } = this.dateRange(); + this.propertyStore.loadProperties({ dateFrom: dateFrom ?? undefined, dateTo: dateTo ?? undefined }); } - loadProperties(): void { - this.propertyStore.loadProperties(this.yearService.selectedYear()); + onDateRangePresetChange(preset: DateRangePreset): void { + this.dateRangePreset.set(preset); + this.dateRange.set(getDateRangeFromPreset(preset)); + } + + onCustomDateRangeChange(range: { dateFrom: string; dateTo: string }): void { + this.dateRangePreset.set('custom'); + this.dateRange.set(range); } navigateToProperty(propertyId: string): void { diff --git a/frontend/src/app/features/expenses/stores/expense-list.store.ts b/frontend/src/app/features/expenses/stores/expense-list.store.ts index 098c63df..e08aad4e 100644 --- a/frontend/src/app/features/expenses/stores/expense-list.store.ts +++ b/frontend/src/app/features/expenses/stores/expense-list.store.ts @@ -159,6 +159,7 @@ export const ExpenseListStore = signalStore( 'this-month': 'This Month', 'this-quarter': 'This Quarter', 'this-year': 'This Year', + 'last-year': 'Last Year', 'custom': 'Custom Range', 'all': 'All Time', }; diff --git a/frontend/src/app/features/income/income.component.spec.ts b/frontend/src/app/features/income/income.component.spec.ts index fd3c7855..0eb70006 100644 --- a/frontend/src/app/features/income/income.component.spec.ts +++ b/frontend/src/app/features/income/income.component.spec.ts @@ -10,7 +10,6 @@ import { By } from '@angular/platform-browser'; import { IncomeComponent } from './income.component'; import { IncomeListStore } from './stores/income-list.store'; import { PropertyStore } from '../properties/stores/property.store'; -import { YearSelectorService } from '../../core/services/year-selector.service'; /** * Unit tests for IncomeComponent (AC-4.3.1, AC-4.3.2, AC-4.3.3, AC-4.3.4, AC-4.3.5, AC-4.3.6) @@ -68,10 +67,6 @@ describe('IncomeComponent', () => { loadProperties: vi.fn(), }; - const mockYearService = { - selectedYear: signal(2026), - }; - beforeEach(async () => { vi.clearAllMocks(); @@ -85,7 +80,6 @@ describe('IncomeComponent', () => { provideRouter([]), { provide: IncomeListStore, useValue: mockIncomeListStore }, { provide: PropertyStore, useValue: mockPropertyStore }, - { provide: YearSelectorService, useValue: mockYearService }, ], }).compileComponents(); @@ -217,10 +211,6 @@ describe('IncomeComponent loading state', () => { loadProperties: vi.fn(), }; - const mockYearService = { - selectedYear: signal(2026), - }; - beforeEach(async () => { await TestBed.configureTestingModule({ imports: [IncomeComponent], @@ -232,7 +222,6 @@ describe('IncomeComponent loading state', () => { provideRouter([]), { provide: IncomeListStore, useValue: mockIncomeListStore }, { provide: PropertyStore, useValue: mockPropertyStore }, - { provide: YearSelectorService, useValue: mockYearService }, ], }).compileComponents(); @@ -280,10 +269,6 @@ describe('IncomeComponent error state', () => { loadProperties: vi.fn(), }; - const mockYearService = { - selectedYear: signal(2026), - }; - beforeEach(async () => { await TestBed.configureTestingModule({ imports: [IncomeComponent], @@ -295,7 +280,6 @@ describe('IncomeComponent error state', () => { provideRouter([]), { provide: IncomeListStore, useValue: mockIncomeListStore }, { provide: PropertyStore, useValue: mockPropertyStore }, - { provide: YearSelectorService, useValue: mockYearService }, ], }).compileComponents(); @@ -349,10 +333,6 @@ describe('IncomeComponent truly empty state (AC-4.3.5)', () => { loadProperties: vi.fn(), }; - const mockYearService = { - selectedYear: signal(2026), - }; - beforeEach(async () => { await TestBed.configureTestingModule({ imports: [IncomeComponent], @@ -364,7 +344,6 @@ describe('IncomeComponent truly empty state (AC-4.3.5)', () => { provideRouter([]), { provide: IncomeListStore, useValue: mockIncomeListStore }, { provide: PropertyStore, useValue: mockPropertyStore }, - { provide: YearSelectorService, useValue: mockYearService }, ], }).compileComponents(); @@ -420,10 +399,6 @@ describe('IncomeComponent filtered empty state (AC-4.3.5)', () => { loadProperties: vi.fn(), }; - const mockYearService = { - selectedYear: signal(2026), - }; - beforeEach(async () => { vi.clearAllMocks(); @@ -437,7 +412,6 @@ describe('IncomeComponent filtered empty state (AC-4.3.5)', () => { provideRouter([]), { provide: IncomeListStore, useValue: mockIncomeListStore }, { provide: PropertyStore, useValue: mockPropertyStore }, - { provide: YearSelectorService, useValue: mockYearService }, ], }).compileComponents(); @@ -509,10 +483,6 @@ describe('IncomeComponent property filter (AC-4.3.4)', () => { loadProperties: vi.fn(), }; - const mockYearService = { - selectedYear: signal(2026), - }; - beforeEach(async () => { vi.clearAllMocks(); @@ -526,7 +496,6 @@ describe('IncomeComponent property filter (AC-4.3.4)', () => { provideRouter([]), { provide: IncomeListStore, useValue: mockIncomeListStore }, { provide: PropertyStore, useValue: mockPropertyStore }, - { provide: YearSelectorService, useValue: mockYearService }, ], }).compileComponents(); diff --git a/frontend/src/app/features/income/income.component.ts b/frontend/src/app/features/income/income.component.ts index b8e3770a..d228676e 100644 --- a/frontend/src/app/features/income/income.component.ts +++ b/frontend/src/app/features/income/income.component.ts @@ -25,7 +25,6 @@ import { ConfirmDialogComponent, ConfirmDialogData, } from '../../shared/components/confirm-dialog/confirm-dialog.component'; -import { YearSelectorService } from '../../core/services/year-selector.service'; import { formatDateShort } from '../../shared/utils/date.utils'; import { DateRangePreset } from '../../shared/utils/date-range.utils'; import { DateRangeFilterComponent } from '../../shared/components/date-range-filter/date-range-filter.component'; @@ -478,7 +477,6 @@ import { ListTotalDisplayComponent } from '../../shared/components/list-total-di export class IncomeComponent implements OnInit, OnDestroy { readonly incomeStore = inject(IncomeListStore); readonly propertyStore = inject(PropertyStore); - private readonly yearService = inject(YearSelectorService); private readonly router = inject(Router); private readonly dialog = inject(MatDialog); private readonly snackBar = inject(MatSnackBar); @@ -492,12 +490,6 @@ export class IncomeComponent implements OnInit, OnDestroy { // Computed signal for date range preset dateRangePreset = computed(() => this.incomeStore.dateRangePreset()); - // Effect to react to year changes (AC-4.3.2 - respects global tax year selector) - private yearEffect = effect(() => { - const year = this.yearService.selectedYear(); - this.incomeStore.setYear(year); - }); - // Sync search input with store state private searchEffect = effect(() => { const search = this.incomeStore.searchText(); diff --git a/frontend/src/app/features/income/stores/income-list.store.spec.ts b/frontend/src/app/features/income/stores/income-list.store.spec.ts index b0dfff08..33107a48 100644 --- a/frontend/src/app/features/income/stores/income-list.store.spec.ts +++ b/frontend/src/app/features/income/stores/income-list.store.spec.ts @@ -177,19 +177,6 @@ describe('IncomeListStore (AC-4.3.1, AC-4.3.3, AC-4.3.4, AC-4.3.5, AC-4.3.6)', ( }); }); - describe('setYear', () => { - it('should filter by year', () => { - // Act - store.setYear(2025); - - // Assert - expect(incomeServiceMock.getAllIncome).toHaveBeenCalledWith( - expect.objectContaining({ - year: 2025, - }) - ); - }); - }); describe('clearFilters (AC-4.3.5)', () => { it('should clear all filters', () => { diff --git a/frontend/src/app/features/income/stores/income-list.store.ts b/frontend/src/app/features/income/stores/income-list.store.ts index 80a18de5..7fc9ab17 100644 --- a/frontend/src/app/features/income/stores/income-list.store.ts +++ b/frontend/src/app/features/income/stores/income-list.store.ts @@ -42,7 +42,6 @@ interface IncomeListState { dateTo: string | null; selectedPropertyId: string | null; searchText: string; - year: number | null; // Loading states isLoading: boolean; @@ -70,7 +69,6 @@ const initialState: IncomeListState = { dateTo: null, selectedPropertyId: null, searchText: '', - year: null, // Loading states isLoading: false, @@ -155,14 +153,13 @@ export const IncomeListStore = signalStore( currentFilters: computed((): IncomeFilterParams => { const { dateFrom, dateTo } = store.dateRangePreset() === 'custom' ? { dateFrom: store.dateFrom(), dateTo: store.dateTo() } - : getDateRangeFromPreset(store.dateRangePreset(), store.year()); + : getDateRangeFromPreset(store.dateRangePreset()); return { dateFrom: dateFrom ?? undefined, dateTo: dateTo ?? undefined, propertyId: store.selectedPropertyId() ?? undefined, search: store.searchText().trim() || undefined, - year: store.year() ?? undefined, }; }), @@ -210,7 +207,7 @@ export const IncomeListStore = signalStore( * Set date range preset and reload (AC-4.3.3) */ setDateRangePreset(preset: DateRangePreset): void { - const { dateFrom, dateTo } = getDateRangeFromPreset(preset, store.year()); + const { dateFrom, dateTo } = getDateRangeFromPreset(preset); patchState(store, { dateRangePreset: preset, dateFrom, dateTo }); persistIncomeDateFilter(preset, dateFrom, dateTo); this.loadIncome(store.currentFilters()); @@ -242,14 +239,6 @@ export const IncomeListStore = signalStore( this.loadIncome(store.currentFilters()); }, - /** - * Set tax year filter and reload (AC-4.3.2) - */ - setYear(year: number | null): void { - patchState(store, { year }); - this.loadIncome(store.currentFilters()); - }, - /** * Clear all filters (AC-4.3.5) */ diff --git a/frontend/src/app/features/properties/properties.component.spec.ts b/frontend/src/app/features/properties/properties.component.spec.ts index d39ab511..7caa4733 100644 --- a/frontend/src/app/features/properties/properties.component.spec.ts +++ b/frontend/src/app/features/properties/properties.component.spec.ts @@ -5,7 +5,6 @@ import { signal } from '@angular/core'; import { By } from '@angular/platform-browser'; import { PropertiesComponent } from './properties.component'; import { PropertyStore } from './stores/property.store'; -import { YearSelectorService } from '../../core/services/year-selector.service'; /** * Unit tests for PropertiesComponent (AC-2.1.1) @@ -35,10 +34,6 @@ describe('PropertiesComponent', () => { loadProperties: vi.fn(), }; - const mockYearService = { - selectedYear: signal(2026), - }; - beforeEach(async () => { vi.clearAllMocks(); @@ -50,7 +45,6 @@ describe('PropertiesComponent', () => { { path: 'properties/:id', component: PropertiesComponent }, ]), { provide: PropertyStore, useValue: mockPropertyStore }, - { provide: YearSelectorService, useValue: mockYearService }, ], }).compileComponents(); @@ -127,17 +121,12 @@ describe('PropertiesComponent loading state', () => { loadProperties: vi.fn(), }; - const mockYearService = { - selectedYear: signal(2026), - }; - beforeEach(async () => { await TestBed.configureTestingModule({ imports: [PropertiesComponent], providers: [ provideRouter([]), { provide: PropertyStore, useValue: mockPropertyStore }, - { provide: YearSelectorService, useValue: mockYearService }, ], }).compileComponents(); @@ -169,10 +158,6 @@ describe('PropertiesComponent error state', () => { loadProperties: vi.fn(), }; - const mockYearService = { - selectedYear: signal(2026), - }; - beforeEach(async () => { vi.clearAllMocks(); @@ -181,7 +166,6 @@ describe('PropertiesComponent error state', () => { providers: [ provideRouter([]), { provide: PropertyStore, useValue: mockPropertyStore }, - { provide: YearSelectorService, useValue: mockYearService }, ], }).compileComponents(); @@ -197,7 +181,7 @@ describe('PropertiesComponent error state', () => { it('should call loadProperties when retry is triggered', () => { component.loadProperties(); - expect(mockPropertyStore.loadProperties).toHaveBeenCalledWith(2026); + expect(mockPropertyStore.loadProperties).toHaveBeenCalled(); }); }); @@ -213,17 +197,12 @@ describe('PropertiesComponent empty state', () => { loadProperties: vi.fn(), }; - const mockYearService = { - selectedYear: signal(2026), - }; - beforeEach(async () => { await TestBed.configureTestingModule({ imports: [PropertiesComponent], providers: [ provideRouter([]), { provide: PropertyStore, useValue: mockPropertyStore }, - { provide: YearSelectorService, useValue: mockYearService }, ], }).compileComponents(); diff --git a/frontend/src/app/features/properties/properties.component.ts b/frontend/src/app/features/properties/properties.component.ts index bace0fa3..0cec0c90 100644 --- a/frontend/src/app/features/properties/properties.component.ts +++ b/frontend/src/app/features/properties/properties.component.ts @@ -1,15 +1,16 @@ -import { Component, inject, OnInit, effect } from '@angular/core'; +import { Component, inject, effect, signal, computed } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Router, RouterLink } from '@angular/router'; import { MatCardModule } from '@angular/material/card'; import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; import { PropertyStore } from './stores/property.store'; -import { YearSelectorService } from '../../core/services/year-selector.service'; import { PropertyRowComponent } from '../../shared/components/property-row/property-row.component'; import { EmptyStateComponent } from '../../shared/components/empty-state/empty-state.component'; import { ErrorCardComponent } from '../../shared/components/error-card/error-card.component'; import { LoadingSpinnerComponent } from '../../shared/components/loading-spinner/loading-spinner.component'; +import { DateRangeFilterComponent } from '../../shared/components/date-range-filter/date-range-filter.component'; +import { DateRangePreset, getDateRangeFromPreset } from '../../shared/utils/date-range.utils'; /** * Properties list component (AC-2.1.1) @@ -28,6 +29,7 @@ import { LoadingSpinnerComponent } from '../../shared/components/loading-spinner EmptyStateComponent, ErrorCardComponent, LoadingSpinnerComponent, + DateRangeFilterComponent, ], template: `
@@ -39,6 +41,17 @@ import { LoadingSpinnerComponent } from '../../shared/components/loading-spinner + + + + + @if (propertyStore.isLoading()) { @@ -113,6 +126,11 @@ import { LoadingSpinnerComponent } from '../../shared/components/loading-spinner } } + .filters-card { + margin-bottom: 24px; + padding: 16px; + } + .properties-content { display: flex; justify-content: center; @@ -152,25 +170,35 @@ import { LoadingSpinnerComponent } from '../../shared/components/loading-spinner } `] }) -export class PropertiesComponent implements OnInit { +export class PropertiesComponent { private readonly router = inject(Router); readonly propertyStore = inject(PropertyStore); - readonly yearService = inject(YearSelectorService); + + readonly dateRangePreset = signal('this-year'); + private readonly dateRange = signal(getDateRangeFromPreset('this-year')); + readonly dateFrom = computed(() => this.dateRange().dateFrom); + readonly dateTo = computed(() => this.dateRange().dateTo); constructor() { - // React to year changes and reload properties (AC-3.5.8) effect(() => { - const year = this.yearService.selectedYear(); - this.propertyStore.loadProperties(year); + const { dateFrom, dateTo } = this.dateRange(); + this.propertyStore.loadProperties({ dateFrom: dateFrom ?? undefined, dateTo: dateTo ?? undefined }); }); } - ngOnInit(): void { - // Initial load happens via effect when selectedYear signal is read + loadProperties(): void { + const { dateFrom, dateTo } = this.dateRange(); + this.propertyStore.loadProperties({ dateFrom: dateFrom ?? undefined, dateTo: dateTo ?? undefined }); } - loadProperties(): void { - this.propertyStore.loadProperties(this.yearService.selectedYear()); + onDateRangePresetChange(preset: DateRangePreset): void { + this.dateRangePreset.set(preset); + this.dateRange.set(getDateRangeFromPreset(preset)); + } + + onCustomDateRangeChange(range: { dateFrom: string; dateTo: string }): void { + this.dateRangePreset.set('custom'); + this.dateRange.set(range); } navigateToProperty(propertyId: string): void { diff --git a/frontend/src/app/features/properties/property-detail/property-detail.component.ts b/frontend/src/app/features/properties/property-detail/property-detail.component.ts index 57d3e5fb..7c05742c 100644 --- a/frontend/src/app/features/properties/property-detail/property-detail.component.ts +++ b/frontend/src/app/features/properties/property-detail/property-detail.component.ts @@ -12,7 +12,8 @@ import { BreakpointObserver } from '@angular/cdk/layout'; import { Subject, takeUntil } from 'rxjs'; import { PropertyStore } from '../stores/property.store'; import { PropertyPhotoStore } from '../stores/property-photo.store'; -import { YearSelectorService } from '../../../core/services/year-selector.service'; +import { DateRangeFilterComponent } from '../../../shared/components/date-range-filter/date-range-filter.component'; +import { DateRangePreset, getDateRangeFromPreset } from '../../../shared/utils/date-range.utils'; import { PropertyPhotoGalleryComponent, PropertyPhoto } from '../components/property-photo-gallery/property-photo-gallery.component'; import { PropertyPhotoUploadComponent } from '../components/property-photo-upload/property-photo-upload.component'; import { PropertyWorkOrdersComponent } from '../components/property-work-orders/property-work-orders.component'; @@ -60,6 +61,7 @@ import { PropertyPhotoUploadComponent, PropertyWorkOrdersComponent, PropertyIncomeComponent, + DateRangeFilterComponent, ], template: `
@@ -173,6 +175,17 @@ import {
+ + + + +
@@ -181,7 +194,7 @@ import { trending_down
- YTD Expenses + {{ expenseLabel() }} {{ propertyStore.selectedProperty()!.expenseTotal | currency }}
@@ -193,7 +206,7 @@ import { trending_up
- YTD Income + {{ incomeLabel() }} {{ propertyStore.selectedProperty()!.incomeTotal | currency }}
@@ -392,6 +405,11 @@ import { } } + .filters-card { + margin-bottom: 24px; + padding: 16px; + } + .stats-section { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); @@ -644,7 +662,6 @@ export class PropertyDetailComponent implements OnInit, OnDestroy { private readonly breakpointObserver = inject(BreakpointObserver); readonly propertyStore = inject(PropertyStore); readonly photoStore = inject(PropertyPhotoStore); - readonly yearService = inject(YearSelectorService); private propertyId: string | null = null; private readonly destroy$ = new Subject(); @@ -655,6 +672,16 @@ export class PropertyDetailComponent implements OnInit, OnDestroy { /** Show upload dialog overlay */ showUploadDialog = false; + /** Date range filter state */ + readonly dateRangePreset = signal('this-year'); + private readonly dateRange = signal(getDateRangeFromPreset('this-year')); + readonly dateFrom = computed(() => this.dateRange().dateFrom); + readonly dateTo = computed(() => this.dateRange().dateTo); + + /** Dynamic stat labels based on preset */ + readonly expenseLabel = computed(() => this.dateRangePreset() === 'this-year' ? 'YTD Expenses' : 'Expenses'); + readonly incomeLabel = computed(() => this.dateRangePreset() === 'this-year' ? 'YTD Income' : 'Income'); + /** * Convert store photos to gallery-compatible format */ @@ -672,19 +699,19 @@ export class PropertyDetailComponent implements OnInit, OnDestroy { ); constructor() { - // React to year changes and reload property detail (AC-3.5.6) + // Read property ID from route early so the effect can use it + this.propertyId = this.route.snapshot.paramMap.get('id'); + effect(() => { - const year = this.yearService.selectedYear(); + const { dateFrom, dateTo } = this.dateRange(); if (this.propertyId) { - this.propertyStore.loadPropertyById({ id: this.propertyId, year }); + this.propertyStore.loadPropertyById({ id: this.propertyId, dateFrom: dateFrom ?? undefined, dateTo: dateTo ?? undefined }); } }); } ngOnInit(): void { - // Get property ID from route and load (AC-2.3.1) - this.propertyId = this.route.snapshot.paramMap.get('id'); - // Initial load happens via effect when selectedYear signal is read + // Property ID already set in constructor for effect to use // Load photos for the property (AC-13.3b.2) if (this.propertyId) { @@ -712,6 +739,16 @@ export class PropertyDetailComponent implements OnInit, OnDestroy { this.router.navigate(['/properties']); } + onDateRangePresetChange(preset: DateRangePreset): void { + this.dateRangePreset.set(preset); + this.dateRange.set(getDateRangeFromPreset(preset)); + } + + onCustomDateRangeChange(range: { dateFrom: string; dateTo: string }): void { + this.dateRangePreset.set('custom'); + this.dateRange.set(range); + } + /** * Format net income with accounting format for negative values (AC-4.4.4) * Positive: $1,234.00 @@ -744,7 +781,7 @@ export class PropertyDetailComponent implements OnInit, OnDestroy { const dialogData: ReportDialogData = { propertyId: property.id, propertyName: property.name, - currentYear: this.yearService.selectedYear() + currentYear: new Date().getFullYear() }; this.dialog.open(ReportDialogComponent, { diff --git a/frontend/src/app/features/properties/services/property.service.spec.ts b/frontend/src/app/features/properties/services/property.service.spec.ts index 4105338c..12473d3e 100644 --- a/frontend/src/app/features/properties/services/property.service.spec.ts +++ b/frontend/src/app/features/properties/services/property.service.spec.ts @@ -113,7 +113,7 @@ describe('PropertyService', () => { totalCount: 1 }; - service.getProperties(2024).subscribe(response => { + service.getProperties({ year: 2024 }).subscribe(response => { expect(response.items).toHaveLength(1); }); @@ -172,7 +172,7 @@ describe('PropertyService', () => { }); it('should get a property by ID filtered by year', () => { - service.getPropertyById('prop-1', 2024).subscribe(response => { + service.getPropertyById('prop-1', { year: 2024 }).subscribe(response => { expect(response.id).toBe('prop-1'); expect(response.expenseTotal).toBe(5000); expect(response.incomeTotal).toBe(18000); diff --git a/frontend/src/app/features/properties/services/property.service.ts b/frontend/src/app/features/properties/services/property.service.ts index 7e496c9b..7eab723f 100644 --- a/frontend/src/app/features/properties/services/property.service.ts +++ b/frontend/src/app/features/properties/services/property.service.ts @@ -75,9 +75,14 @@ export class PropertyService { * @param year Optional tax year filter for expense/income totals * @returns Observable with properties list and total count */ - getProperties(year?: number): Observable { - const params = year ? { year: year.toString() } : undefined; - return this.http.get(this.baseUrl, { params }); + getProperties(params?: { year?: number; dateFrom?: string; dateTo?: string }): Observable { + const httpParams: Record = {}; + if (params?.year) httpParams['year'] = params.year.toString(); + if (params?.dateFrom) httpParams['dateFrom'] = params.dateFrom; + if (params?.dateTo) httpParams['dateTo'] = params.dateTo; + return this.http.get(this.baseUrl, { + params: Object.keys(httpParams).length > 0 ? httpParams : undefined, + }); } /** @@ -86,9 +91,14 @@ export class PropertyService { * @param year Optional tax year filter for expense/income totals * @returns Observable with property detail or 404 error */ - getPropertyById(id: string, year?: number): Observable { - const params = year ? { year: year.toString() } : undefined; - return this.http.get(`${this.baseUrl}/${id}`, { params }); + getPropertyById(id: string, params?: { year?: number; dateFrom?: string; dateTo?: string }): Observable { + const httpParams: Record = {}; + if (params?.year) httpParams['year'] = params.year.toString(); + if (params?.dateFrom) httpParams['dateFrom'] = params.dateFrom; + if (params?.dateTo) httpParams['dateTo'] = params.dateTo; + return this.http.get(`${this.baseUrl}/${id}`, { + params: Object.keys(httpParams).length > 0 ? httpParams : undefined, + }); } /** diff --git a/frontend/src/app/features/properties/stores/property.store.spec.ts b/frontend/src/app/features/properties/stores/property.store.spec.ts index 2304bde4..dd95db82 100644 --- a/frontend/src/app/features/properties/stores/property.store.spec.ts +++ b/frontend/src/app/features/properties/stores/property.store.spec.ts @@ -82,8 +82,12 @@ describe('PropertyStore', () => { expect(store.error()).toBeNull(); }); - it('should have null selected year initially', () => { - expect(store.selectedYear()).toBeNull(); + it('should have null dateFrom initially', () => { + expect(store.dateFrom()).toBeNull(); + }); + + it('should have null dateTo initially', () => { + expect(store.dateTo()).toBeNull(); }); }); @@ -168,18 +172,19 @@ describe('PropertyStore', () => { expect(propertyServiceSpy.getProperties).toHaveBeenCalledWith(undefined); }); - it('should call service with year parameter', async () => { - store.loadProperties(2024); + it('should call service with date range parameters', async () => { + store.loadProperties({ dateFrom: '2024-01-01', dateTo: '2024-12-31' }); await new Promise(resolve => setTimeout(resolve, 0)); - expect(propertyServiceSpy.getProperties).toHaveBeenCalledWith(2024); + expect(propertyServiceSpy.getProperties).toHaveBeenCalledWith({ dateFrom: '2024-01-01', dateTo: '2024-12-31' }); }); - it('should set selectedYear when loading with year', async () => { - store.loadProperties(2024); + it('should set dateFrom/dateTo when loading with date range', async () => { + store.loadProperties({ dateFrom: '2024-01-01', dateTo: '2024-12-31' }); await new Promise(resolve => setTimeout(resolve, 0)); - expect(store.selectedYear()).toBe(2024); + expect(store.dateFrom()).toBe('2024-01-01'); + expect(store.dateTo()).toBe('2024-12-31'); }); it('should handle error gracefully', async () => { @@ -237,7 +242,7 @@ describe('PropertyStore', () => { it('should reset store to initial state', async () => { propertyServiceSpy.getProperties.mockReturnValue(of(mockPropertiesResponse)); - store.loadProperties(2024); + store.loadProperties({ dateFrom: '2024-01-01', dateTo: '2024-12-31' }); await new Promise(resolve => setTimeout(resolve, 0)); expect(store.properties().length).toBeGreaterThan(0); @@ -247,20 +252,8 @@ describe('PropertyStore', () => { expect(store.properties()).toEqual([]); expect(store.isLoading()).toBe(false); expect(store.error()).toBeNull(); - expect(store.selectedYear()).toBeNull(); - }); - }); - - describe('setSelectedYear', () => { - it('should set selected year', () => { - store.setSelectedYear(2024); - expect(store.selectedYear()).toBe(2024); - }); - - it('should allow setting null year', () => { - store.setSelectedYear(2024); - store.setSelectedYear(null); - expect(store.selectedYear()).toBeNull(); + expect(store.dateFrom()).toBeNull(); + expect(store.dateTo()).toBeNull(); }); }); @@ -293,14 +286,14 @@ describe('PropertyStore', () => { store.loadPropertyById({ id: 'test-id' }); await new Promise(resolve => setTimeout(resolve, 0)); - expect(propertyServiceSpy.getPropertyById).toHaveBeenCalledWith('test-id', undefined); + expect(propertyServiceSpy.getPropertyById).toHaveBeenCalledWith('test-id', { dateFrom: undefined, dateTo: undefined }); }); - it('should call service with property ID and year', async () => { - store.loadPropertyById({ id: 'test-id', year: 2024 }); + it('should call service with property ID and date range', async () => { + store.loadPropertyById({ id: 'test-id', dateFrom: '2024-01-01', dateTo: '2024-12-31' }); await new Promise(resolve => setTimeout(resolve, 0)); - expect(propertyServiceSpy.getPropertyById).toHaveBeenCalledWith('test-id', 2024); + expect(propertyServiceSpy.getPropertyById).toHaveBeenCalledWith('test-id', { dateFrom: '2024-01-01', dateTo: '2024-12-31' }); }); it('should handle 404 error with specific message', async () => { diff --git a/frontend/src/app/features/properties/stores/property.store.ts b/frontend/src/app/features/properties/stores/property.store.ts index 38a7c5bf..78448c7c 100644 --- a/frontend/src/app/features/properties/stores/property.store.ts +++ b/frontend/src/app/features/properties/stores/property.store.ts @@ -24,7 +24,8 @@ interface PropertyState { properties: PropertySummaryDto[]; isLoading: boolean; error: string | null; - selectedYear: number | null; + dateFrom: string | null; + dateTo: string | null; // Property detail state (AC-2.3.2) selectedProperty: PropertyDetailDto | null; isLoadingDetail: boolean; @@ -44,7 +45,8 @@ const initialState: PropertyState = { properties: [], isLoading: false, error: null, - selectedYear: null, + dateFrom: null, + dateTo: null, // Property detail initial state selectedProperty: null, isLoadingDetail: false, @@ -137,7 +139,7 @@ export const PropertyStore = signalStore( * Load properties from API * @param year Optional tax year filter */ - loadProperties: rxMethod( + loadProperties: rxMethod<{ dateFrom?: string; dateTo?: string } | undefined>( pipe( tap(() => patchState(store, { @@ -145,13 +147,14 @@ export const PropertyStore = signalStore( error: null, }) ), - switchMap((year) => - propertyService.getProperties(year).pipe( + switchMap((params) => + propertyService.getProperties(params).pipe( tap((response) => patchState(store, { properties: response.items, isLoading: false, - selectedYear: year ?? null, + dateFrom: params?.dateFrom ?? null, + dateTo: params?.dateTo ?? null, }) ), catchError((error) => { @@ -181,18 +184,11 @@ export const PropertyStore = signalStore( patchState(store, initialState); }, - /** - * Set selected year filter - */ - setSelectedYear(year: number | null): void { - patchState(store, { selectedYear: year }); - }, - /** * Load a single property by ID (AC-2.3.2, AC-2.3.5, AC-3.5.6) - * @param params Object containing id and optional year filter + * @param params Object containing id and optional date range filter */ - loadPropertyById: rxMethod<{ id: string; year?: number }>( + loadPropertyById: rxMethod<{ id: string; dateFrom?: string; dateTo?: string }>( pipe( tap(() => patchState(store, { @@ -201,8 +197,8 @@ export const PropertyStore = signalStore( selectedProperty: null, }) ), - switchMap(({ id, year }) => - propertyService.getPropertyById(id, year).pipe( + switchMap(({ id, dateFrom, dateTo }) => + propertyService.getPropertyById(id, { dateFrom, dateTo }).pipe( tap((property) => patchState(store, { selectedProperty: property, diff --git a/frontend/src/app/features/reports/components/batch-report-dialog/batch-report-dialog.component.spec.ts b/frontend/src/app/features/reports/components/batch-report-dialog/batch-report-dialog.component.spec.ts index 5ddfcc16..e8d4faf0 100644 --- a/frontend/src/app/features/reports/components/batch-report-dialog/batch-report-dialog.component.spec.ts +++ b/frontend/src/app/features/reports/components/batch-report-dialog/batch-report-dialog.component.spec.ts @@ -2,11 +2,11 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { MatDialogRef } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { signal } from '@angular/core'; +import { of, throwError } from 'rxjs'; import { BatchReportDialogComponent } from './batch-report-dialog.component'; import { ReportService } from '../../services/report.service'; -import { PropertyStore } from '../../../properties/stores/property.store'; -import { YearSelectorService } from '../../../../core/services/year-selector.service'; +import { PropertyService } from '../../../properties/services/property.service'; + describe('BatchReportDialogComponent', () => { let component: BatchReportDialogComponent; @@ -14,30 +14,24 @@ describe('BatchReportDialogComponent', () => { let mockReportService: Partial; let mockDialogRef: Partial>; let mockSnackBar: Partial; + let mockPropertyService: Partial; const mockProperties = [ - { id: 'prop-1', name: 'Property 1', city: 'Austin', state: 'TX', incomeTotal: 1000, expenseTotal: 500 }, - { id: 'prop-2', name: 'Property 2', city: 'Dallas', state: 'TX', incomeTotal: 0, expenseTotal: 0 }, - { id: 'prop-3', name: 'Property 3', city: 'Houston', state: 'TX', incomeTotal: 2000, expenseTotal: 800 }, + { id: 'prop-1', name: 'Property 1', street: '123 Main', city: 'Austin', state: 'TX', zipCode: '78701', incomeTotal: 1000, expenseTotal: 500 }, + { id: 'prop-2', name: 'Property 2', street: '456 Oak', city: 'Dallas', state: 'TX', zipCode: '75201', incomeTotal: 0, expenseTotal: 0 }, + { id: 'prop-3', name: 'Property 3', street: '789 Elm', city: 'Houston', state: 'TX', zipCode: '77001', incomeTotal: 2000, expenseTotal: 800 }, ]; - const mockPropertyStore = { - properties: signal(mockProperties), - isLoading: signal(false), - loadProperties: vi.fn(), - }; - - const mockYearService = { - selectedYear: signal(2024), - availableYears: signal([2024, 2023, 2022]), - }; - beforeEach(async () => { mockReportService = { generateBatchScheduleE: vi.fn(), downloadZip: vi.fn() }; + mockPropertyService = { + getProperties: vi.fn().mockReturnValue(of({ items: mockProperties, totalCount: 3 })), + }; + mockDialogRef = { close: vi.fn() }; @@ -50,8 +44,7 @@ describe('BatchReportDialogComponent', () => { imports: [BatchReportDialogComponent, NoopAnimationsModule], providers: [ { provide: ReportService, useValue: mockReportService }, - { provide: PropertyStore, useValue: mockPropertyStore }, - { provide: YearSelectorService, useValue: mockYearService }, + { provide: PropertyService, useValue: mockPropertyService }, { provide: MatDialogRef, useValue: mockDialogRef }, { provide: MatSnackBar, useValue: mockSnackBar }, ], @@ -67,6 +60,14 @@ describe('BatchReportDialogComponent', () => { }); describe('initialization', () => { + it('should fetch properties with year-scoped date range on init', () => { + const year = new Date().getFullYear(); + expect(mockPropertyService.getProperties).toHaveBeenCalledWith({ + dateFrom: `${year}-01-01`, + dateTo: `${year}-12-31`, + }); + }); + it('should load all properties on init', () => { expect(component.properties().length).toBe(3); }); @@ -85,6 +86,26 @@ describe('BatchReportDialogComponent', () => { const hasDataProperty = component.properties().find(p => p.id === 'prop-1'); expect(hasDataProperty?.hasDataForYear).toBe(true); }); + + it('should set error if property fetch fails', () => { + (mockPropertyService.getProperties as ReturnType).mockReturnValue( + throwError(() => new Error('fail')) + ); + component.ngOnInit(); + expect(component.error()).toBe('Failed to load properties.'); + }); + }); + + describe('year change', () => { + it('should reload properties when year changes', () => { + (mockPropertyService.getProperties as ReturnType).mockClear(); + component.selectedYear = 2024; + component.onYearChange(); + expect(mockPropertyService.getProperties).toHaveBeenCalledWith({ + dateFrom: '2024-01-01', + dateTo: '2024-12-31', + }); + }); }); describe('property selection', () => { @@ -124,7 +145,7 @@ describe('BatchReportDialogComponent', () => { expect(mockReportService.generateBatchScheduleE).toHaveBeenCalledWith( ['prop-1', 'prop-2', 'prop-3'], - 2024 + new Date().getFullYear() ); }); @@ -134,7 +155,7 @@ describe('BatchReportDialogComponent', () => { await component.generate(); - expect(mockReportService.downloadZip).toHaveBeenCalledWith(mockBlob, 2024); + expect(mockReportService.downloadZip).toHaveBeenCalledWith(mockBlob, new Date().getFullYear()); }); it('should show snackbar on success', async () => { @@ -200,7 +221,7 @@ describe('BatchReportDialogComponent', () => { expect(mockReportService.generateBatchScheduleE).toHaveBeenCalledWith( ['prop-1', 'prop-3'], - 2024 + new Date().getFullYear() ); }); }); @@ -213,13 +234,4 @@ describe('BatchReportDialogComponent', () => { }); }); - describe('year selector', () => { - it('should have 10 year options', () => { - expect(component.availableYears.length).toBe(10); - }); - - it('should default to the current year from service', () => { - expect(component.selectedYear).toBe(2024); - }); - }); }); diff --git a/frontend/src/app/features/reports/components/batch-report-dialog/batch-report-dialog.component.ts b/frontend/src/app/features/reports/components/batch-report-dialog/batch-report-dialog.component.ts index 45366706..c8040bb5 100644 --- a/frontend/src/app/features/reports/components/batch-report-dialog/batch-report-dialog.component.ts +++ b/frontend/src/app/features/reports/components/batch-report-dialog/batch-report-dialog.component.ts @@ -1,4 +1,4 @@ -import { Component, inject, signal, computed, OnInit, effect } from '@angular/core'; +import { Component, inject, signal, computed, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatButtonModule } from '@angular/material/button'; @@ -9,8 +9,7 @@ import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatSnackBar } from '@angular/material/snack-bar'; import { ReportService } from '../../services/report.service'; -import { PropertyStore } from '../../../properties/stores/property.store'; -import { YearSelectorService } from '../../../../core/services/year-selector.service'; +import { PropertyService } from '../../../properties/services/property.service'; /** * Property selection item for the batch dialog. @@ -59,7 +58,7 @@ interface PropertySelection { Tax Year - + @for (year of availableYears; track year) { {{ year }} } @@ -262,12 +261,11 @@ interface PropertySelection { }) export class BatchReportDialogComponent implements OnInit { private readonly reportService = inject(ReportService); - private readonly propertyStore = inject(PropertyStore); - private readonly yearService = inject(YearSelectorService); + private readonly propertyService = inject(PropertyService); private readonly snackBar = inject(MatSnackBar); private readonly dialogRef = inject(MatDialogRef); - selectedYear = this.yearService.selectedYear(); + selectedYear = new Date().getFullYear(); availableYears = this.generateYearOptions(); readonly properties = signal([]); @@ -283,47 +281,36 @@ export class BatchReportDialogComponent implements OnInit { this.properties().every(p => p.selected) ); - constructor() { - // Watch for property changes from the store and update local state - effect(() => { - const storeProperties = this.propertyStore.properties(); - const isLoading = this.propertyStore.isLoading(); - - // Only update when we have properties and loading is complete - if (storeProperties.length > 0 && !isLoading) { - this.initializeProperties(); - } - }); - } - ngOnInit(): void { - // Ensure properties are loaded - trigger load if store is empty - if (this.propertyStore.properties().length === 0 && !this.propertyStore.isLoading()) { - this.propertyStore.loadProperties(this.selectedYear); - } - // Also initialize immediately if properties already exist - if (this.propertyStore.properties().length > 0) { - this.initializeProperties(); - } + this.loadPropertiesForYear(); } /** - * Initialize property selection from store. - * Called on init and when store properties update. + * Fetch properties with year-scoped totals directly from API. + * Avoids reading from the shared PropertyStore whose data reflects + * whatever date filter the previous page applied. */ - private initializeProperties(): void { - // Load properties from store - const storeProperties = this.propertyStore.properties(); - this.properties.set( - storeProperties.map(p => ({ - id: p.id, - name: p.name, - address: this.formatAddress(p), - selected: true, - // Determine if property has data (income or expense > 0) - hasDataForYear: p.incomeTotal > 0 || p.expenseTotal > 0 - })) - ); + private loadPropertiesForYear(): void { + const year = this.selectedYear; + const dateFrom = `${year}-01-01`; + const dateTo = `${year}-12-31`; + + this.propertyService.getProperties({ dateFrom, dateTo }).subscribe({ + next: (response) => { + this.properties.set( + response.items.map(p => ({ + id: p.id, + name: p.name, + address: this.formatAddress(p), + selected: true, + hasDataForYear: p.incomeTotal > 0 || p.expenseTotal > 0, + })) + ); + }, + error: () => { + this.error.set('Failed to load properties.'); + }, + }); } /** @@ -344,6 +331,13 @@ export class BatchReportDialogComponent implements OnInit { return parts.join(', ') || 'No address'; } + /** + * Reload property data when tax year changes. + */ + onYearChange(): void { + this.loadPropertiesForYear(); + } + /** * Toggle selection for a single property. */ diff --git a/frontend/src/app/shared/components/date-range-filter/date-range-filter.component.ts b/frontend/src/app/shared/components/date-range-filter/date-range-filter.component.ts index a7dd9fd7..da93086a 100644 --- a/frontend/src/app/shared/components/date-range-filter/date-range-filter.component.ts +++ b/frontend/src/app/shared/components/date-range-filter/date-range-filter.component.ts @@ -38,6 +38,7 @@ import { formatLocalDate } from '../../utils/date.utils'; This Month This Quarter This Year + Last Year Custom Range diff --git a/frontend/src/app/shared/components/year-selector/year-selector.component.spec.ts b/frontend/src/app/shared/components/year-selector/year-selector.component.spec.ts deleted file mode 100644 index 1234eb9c..00000000 --- a/frontend/src/app/shared/components/year-selector/year-selector.component.spec.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { TestBed, ComponentFixture } from '@angular/core/testing'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { signal } from '@angular/core'; -import { By } from '@angular/platform-browser'; -import { YearSelectorComponent } from './year-selector.component'; -import { YearSelectorService } from '../../../core/services/year-selector.service'; - -describe('YearSelectorComponent', () => { - let component: YearSelectorComponent; - let fixture: ComponentFixture; - let mockYearService: { - selectedYear: ReturnType>; - availableYears: ReturnType>; - setYear: ReturnType; - }; - - const currentYear = new Date().getFullYear(); - const availableYears = [ - currentYear, - currentYear - 1, - currentYear - 2, - currentYear - 3, - currentYear - 4, - currentYear - 5, - ]; - - beforeEach(async () => { - mockYearService = { - selectedYear: signal(currentYear), - availableYears: signal(availableYears), - setYear: vi.fn(), - }; - - await TestBed.configureTestingModule({ - imports: [YearSelectorComponent, NoopAnimationsModule], - providers: [{ provide: YearSelectorService, useValue: mockYearService }], - }).compileComponents(); - - fixture = TestBed.createComponent(YearSelectorComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should inject YearSelectorService', () => { - expect(component.yearService).toBeTruthy(); - }); - - it('should have year-selector-wrapper container', () => { - const wrapper = fixture.debugElement.query(By.css('.year-selector-wrapper')); - expect(wrapper).toBeTruthy(); - }); - - it('should display calendar icon (AC-3.5.1)', () => { - const icon = fixture.debugElement.query(By.css('.calendar-icon')); - expect(icon).toBeTruthy(); - expect(icon.nativeElement.textContent.trim()).toBe('calendar_today'); - }); - - it('should render mat-select element', () => { - const select = fixture.debugElement.query(By.css('mat-select')); - expect(select).toBeTruthy(); - }); - - it('should have aria-label for accessibility', () => { - const select = fixture.debugElement.query(By.css('mat-select')); - expect(select.attributes['aria-label']).toBe('Select tax year'); - }); - - it('should have data-testid attribute', () => { - const select = fixture.debugElement.query(By.css('[data-testid="year-selector"]')); - expect(select).toBeTruthy(); - }); - - it('should display current year from service', () => { - expect(component.yearService.selectedYear()).toBe(currentYear); - }); - - it('should call setYear when year is changed (AC-3.5.5)', () => { - const newYear = currentYear - 1; - component.onYearChange(newYear); - - expect(mockYearService.setYear).toHaveBeenCalledWith(newYear); - }); - - it('should call setYear with correct year value', () => { - const targetYear = currentYear - 2; - component.onYearChange(targetYear); - - expect(mockYearService.setYear).toHaveBeenCalledWith(targetYear); - }); -}); - -describe('YearSelectorComponent with different selected year', () => { - let fixture: ComponentFixture; - let mockYearService: { - selectedYear: ReturnType>; - availableYears: ReturnType>; - setYear: ReturnType; - }; - - const currentYear = new Date().getFullYear(); - const selectedYear = currentYear - 2; - - beforeEach(async () => { - mockYearService = { - selectedYear: signal(selectedYear), - availableYears: signal([ - currentYear, - currentYear - 1, - currentYear - 2, - currentYear - 3, - currentYear - 4, - currentYear - 5, - ]), - setYear: vi.fn(), - }; - - await TestBed.configureTestingModule({ - imports: [YearSelectorComponent, NoopAnimationsModule], - providers: [{ provide: YearSelectorService, useValue: mockYearService }], - }).compileComponents(); - - fixture = TestBed.createComponent(YearSelectorComponent); - fixture.detectChanges(); - }); - - it('should display selected year from service', () => { - expect(fixture.componentInstance.yearService.selectedYear()).toBe(selectedYear); - }); -}); - -describe('YearSelectorComponent available years (AC-3.5.1)', () => { - let component: YearSelectorComponent; - let fixture: ComponentFixture; - let mockYearService: { - selectedYear: ReturnType>; - availableYears: ReturnType>; - setYear: ReturnType; - }; - - const currentYear = new Date().getFullYear(); - const availableYears = [ - currentYear, - currentYear - 1, - currentYear - 2, - currentYear - 3, - currentYear - 4, - currentYear - 5, - ]; - - beforeEach(async () => { - mockYearService = { - selectedYear: signal(currentYear), - availableYears: signal(availableYears), - setYear: vi.fn(), - }; - - await TestBed.configureTestingModule({ - imports: [YearSelectorComponent, NoopAnimationsModule], - providers: [{ provide: YearSelectorService, useValue: mockYearService }], - }).compileComponents(); - - fixture = TestBed.createComponent(YearSelectorComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should have 6 available years from service', () => { - expect(component.yearService.availableYears().length).toBe(6); - }); - - it('should include current year in available years', () => { - expect(component.yearService.availableYears()).toContain(currentYear); - }); - - it('should include 5 previous years', () => { - const years = component.yearService.availableYears(); - expect(years).toContain(currentYear - 1); - expect(years).toContain(currentYear - 2); - expect(years).toContain(currentYear - 3); - expect(years).toContain(currentYear - 4); - expect(years).toContain(currentYear - 5); - }); -}); diff --git a/frontend/src/app/shared/components/year-selector/year-selector.component.ts b/frontend/src/app/shared/components/year-selector/year-selector.component.ts deleted file mode 100644 index d4705e3f..00000000 --- a/frontend/src/app/shared/components/year-selector/year-selector.component.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { Component, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { MatSelectModule } from '@angular/material/select'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; - -import { YearSelectorService } from '../../../core/services/year-selector.service'; - -/** - * Year Selector Component (AC-3.5.1) - * - * Compact dropdown for selecting tax year. - * Designed for placement in toolbar/header areas. - * Binds to YearSelectorService for global state. - */ -@Component({ - selector: 'app-year-selector', - standalone: true, - imports: [ - CommonModule, - FormsModule, - MatSelectModule, - MatFormFieldModule, - MatIconModule, - ], - template: ` -
- calendar_today - - - @for (year of yearService.availableYears(); track year) { - - {{ year }} - - } - - -
- `, - styles: [` - .year-selector-wrapper { - display: flex; - align-items: center; - gap: 12px; - } - - .calendar-icon { - color: rgba(255, 255, 255, 0.8); - font-size: 24px; - width: 24px; - height: 24px; - } - - .year-selector { - width: 90px; - - ::ng-deep { - .mat-mdc-form-field-subscript-wrapper { - display: none; - } - - .mat-mdc-text-field-wrapper { - padding: 0 8px; - background: rgba(255, 255, 255, 0.1); - border-radius: 8px; - } - - .mat-mdc-form-field-flex { - height: 36px; - align-items: center; - } - - .mat-mdc-select-value { - font-size: 14px; - font-weight: 500; - color: white !important; - } - - .mat-mdc-select-value-text { - color: white !important; - } - - .mdc-notched-outline__leading, - .mdc-notched-outline__notch, - .mdc-notched-outline__trailing { - border-color: rgba(255, 255, 255, 0.3) !important; - } - - .mat-mdc-select-arrow { - color: rgba(255, 255, 255, 0.7); - } - } - } - - :host-context(.light-theme) { - .calendar-icon { - color: rgba(0, 0, 0, 0.54); - } - - .year-selector { - ::ng-deep { - .mat-mdc-text-field-wrapper { - background: rgba(0, 0, 0, 0.05); - } - - .mat-mdc-select-value, - .mat-mdc-select-value-text { - color: rgba(0, 0, 0, 0.87) !important; - } - - .mdc-notched-outline__leading, - .mdc-notched-outline__notch, - .mdc-notched-outline__trailing { - border-color: rgba(0, 0, 0, 0.2) !important; - } - - .mat-mdc-select-arrow { - color: rgba(0, 0, 0, 0.54); - } - } - } - } - `] -}) -export class YearSelectorComponent { - readonly yearService = inject(YearSelectorService); - - onYearChange(year: number): void { - this.yearService.setYear(year); - } -} diff --git a/frontend/src/app/shared/utils/date-range.utils.ts b/frontend/src/app/shared/utils/date-range.utils.ts index 5187c3a1..84697e94 100644 --- a/frontend/src/app/shared/utils/date-range.utils.ts +++ b/frontend/src/app/shared/utils/date-range.utils.ts @@ -3,7 +3,7 @@ import { formatLocalDate } from './date.utils'; /** * Date range preset options for filtering (AC-3.4.3) */ -export type DateRangePreset = 'this-month' | 'this-quarter' | 'this-year' | 'custom' | 'all'; +export type DateRangePreset = 'this-month' | 'this-quarter' | 'this-year' | 'last-year' | 'custom' | 'all'; /** * Calculate date range from preset. @@ -38,6 +38,13 @@ export function getDateRangeFromPreset( dateTo: `${currentYear}-12-31`, }; } + case 'last-year': { + const lastYear = today.getFullYear() - 1; + return { + dateFrom: `${lastYear}-01-01`, + dateTo: `${lastYear}-12-31`, + }; + } case 'all': case 'custom': default: diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index cf8eb021..9585417f 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -107,25 +107,6 @@ a { } } -// Year selector dropdown panel - ensure full year is visible -.year-selector-panel { - min-width: 100px !important; - - .mat-mdc-option { - min-width: 90px; - - .mdc-list-item__primary-text { - overflow: visible !important; - text-overflow: clip !important; - } - - // Hide checkmark - green highlight is sufficient indicator - .mat-pseudo-checkbox { - display: none !important; - } - } -} - // Receipt lightbox panel - large modal .receipt-lightbox-panel { .mat-mdc-dialog-container { diff --git a/story-17-10-status-dropdown.png b/story-17-10-status-dropdown.png new file mode 100644 index 00000000..3d2dcc6f Binary files /dev/null and b/story-17-10-status-dropdown.png differ