# Investor Report Refactor + Budget Control Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Split InvestorReportScreen into modular tab screens, add budget-per-unit management, and expense request flow with admin approval using existing internal_transactions table.

**Architecture:** Flutter: split InvestorReportScreen into shell + tab files. Laravel: add budget migration + API endpoints + update service. Expense requests reuse internal_transactions with status=pending/approved/rejected.

**Tech Stack:** Flutter (MobX, dart_mappable), Laravel (Eloquent, DB), TabController + TabBarView for swipe

---

## File Structure

```
# Backend
ksu-app/
  app/Models/Budget.php                          CREATE
  database/migrations/
    2026_05_18_xxxxxx_create_budgets_table.php     CREATE
  app/Services/
    BudgetService.php                             CREATE
  app/Http/Controllers/
    BudgetController.php                          CREATE
  app/Http/Controllers/InvestorReportController.php  MODIFY (add overviewBudget)
  app/Services/InvestorReportServiceImpl.php       MODIFY (add budget to overview)
  routes/api.php                                   MODIFY (budget routes)

# Frontend
ksu_mobile_app/lib/
  screen/report/
    investor_report_screen.dart                   MODIFY (shell + TabController + swipe)
    tabs/
      kas_tab.dart                               CREATE
      profit_tab.dart                             CREATE
      loan_condition_tab.dart                     CREATE
      budget_tab.dart                             CREATE
  widgets/report/
    report_filter_panel.dart                      CREATE
    report_metric_card.dart                      CREATE
    report_line_item.dart                         CREATE
  models/
    budget.dart                                  CREATE
    budget.mapper.dart                           CREATE
  store/
    BudgetStore.dart                             CREATE
    BudgetStore.g.dart                           CREATE
    ExpenseRequestStore.dart                     CREATE
    ExpenseRequestStore.g.dart                   CREATE
```

---

## Backend Tasks

### Task 1: Budget Migration + Model

**Files:**
- Create: `database/migrations/2026_05_18_000000_create_budgets_table.php`
- Create: `app/Models/Budget.php`
- Modify: `app/Models/User.php` (add `budgets()` relationship)

- [ ] **Step 1: Create budget migration**

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('budgets', function (Blueprint $table) {
            $table->id();
            $table->foreignId('unit_id')->constrained()->onDelete('cascade');
            $table->decimal('amount', 15, 0)->default(0);
            $table->string('period', 7); // 'YYYY-MM'
            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
            $table->timestamps();
            $table->unique(['unit_id', 'period'], 'budgets_unit_period_unique');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('budgets');
    }
};
```

- [ ] **Step 2: Create Budget model**

```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Budget extends Model
{
    protected $fillable = ['unit_id', 'amount', 'period', 'created_by'];

    protected $casts = ['amount' => 'decimal:0'];

    public function unit(): BelongsTo
    {
        return $this->belongsTo(Unit::class);
    }

    public function creator(): BelongsTo
    {
        return $this->belongsTo(User::class, 'created_by');
    }
}
```

- [ ] **Step 3: Add budgets relationship to User model** — add after existing relationships:
```php
public function budgets()
{
    return $this->hasMany(Budget::class, 'created_by');
}
```

- [ ] **Step 4: Commit**
```bash
git add database/migrations/2026_05_18_000000_create_budgets_table.php app/Models/Budget.php app/Models/User.php
git commit -m "feat: add budgets table and Budget model"
```

---

### Task 2: BudgetService + BudgetController

**Files:**
- Create: `app/Services/BudgetService.php`
- Create: `app/Http/Controllers/BudgetController.php`
- Modify: `routes/api.php`

- [ ] **Step 1: Create BudgetService**

```php
<?php

namespace App\Services;

use App\Models\Budget;
use App\Models\Unit;
use Carbon\Carbon;
use Illuminate\Support\Collection;

class BudgetService
{
    public function getBudgets(?string $period = null): Collection
    {
        $period = $period ?? Carbon::now()->format('Y-m');

        return Unit::with(['budgets' => fn($q) => $q->where('period', $period)])
            ->get()
            ->map(fn($unit) => [
                'unit_id' => $unit->id,
                'unit_name' => $unit->name,
                'period' => $period,
                'budget' => (double) ($unit->budgets->first()?->amount ?? 0),
            ]);
    }

    public function getBudgetByUnitAndPeriod(int $unitId, string $period): ?Budget
    {
        return Budget::where('unit_id', $unitId)->where('period', $period)->first();
    }

    public function upsertBudget(int $unitId, string $period, float $amount, ?int $createdBy = null): Budget
    {
        return Budget::updateOrCreate(
            ['unit_id' => $unitId, 'period' => $period],
            ['amount' => $amount, 'created_by' => $createdBy]
        );
    }

    public function getBudgetSummary(?string $period = null): array
    {
        $period = $period ?? Carbon::now()->format('Y-m');

        return $this->getBudgets($period)->toArray();
    }
}
```

- [ ] **Step 2: Create BudgetController**

```php
<?php

namespace App\Http\Controllers;

use App\Services\BudgetService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class BudgetController extends Controller
{
    public function __construct(private BudgetService $budgetService) {}

    /**
     * GET /api/report/budgets
     */
    public function index(Request $request): JsonResponse
    {
        $period = $request->get('period', Carbon::now()->format('Y-m'));
        $budgets = $this->budgetService->getBudgets($period);
        return $this->sendResponse(['period' => $period, 'budgets' => $budgets]);
    }

    /**
     * POST /api/report/budgets
     * Create or update budget per unit
     */
    public function store(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'unit_id' => 'required|integer|exists:units,id',
            'period' => 'required|string|regex:/^\d{4}-\d{2}$/',
            'amount' => 'required|numeric|min:0',
        ]);

        $budget = $this->budgetService->upsertBudget(
            $validated['unit_id'],
            $validated['period'],
            (float) $validated['amount'],
            $request->user()?->id,
        );

        return $this->sendResponse(['budget' => $budget], 'Budget saved', 201);
    }
}
```

- [ ] **Step 3: Add budget routes to api.php** — add after investor report routes:
```php
Route::prefix('report/budgets')->group(function () {
    Route::get('/', [BudgetController::class, 'index']);
    Route::post('/', [BudgetController::class, 'store']);
});
```

- [ ] **Step 4: Commit**
```bash
git add app/Services/BudgetService.php app/Http/Controllers/BudgetController.php routes/api.php
git commit -m "feat: add budget management API endpoints"
```

---

### Task 3: Expense Request via InternalTransaction

**Files:**
- Modify: `app/Http/Controllers/InvestorReportController.php`
- Modify: `app/Services/InvestorReportServiceImpl.php`
- Create: `app/Services/ExpenseRequestService.php`
- Create: `app/Http/Controllers/ExpenseRequestController.php`
- Modify: `routes/api.php`

- [ ] **Step 1: Create ExpenseRequestService**

```php
<?php

namespace App\Services;

use App\Models\InternalTransaction;
use App\Models\Unit;
use Carbon\Carbon;
use Illuminate\Support\Collection;

class ExpenseRequestService
{
    /**
     * Get expense requests for admin review.
     * Status: pending | approved | rejected
     */
    public function getRequests(array $filters = []): Collection
    {
        $query = InternalTransaction::query()
            ->whereIn('category', ['EXPENSE_UNIT', 'EXPENSE_RESORT', 'TRANSPORT', 'SALARY'])
            ->with('unit:id,name', 'requestedBy:id,name', 'approvedBy:id,name');

        if (!empty($filters['status'])) {
            $query->where('status', $filters['status']);
        }
        if (!empty($filters['unit_id'])) {
            $query->where('unit_id', $filters['unit_id']);
        }

        return $query->orderBy('created_at', 'desc')->get();
    }

    /**
     * Create expense request (status = pending)
     */
    public function createRequest(array $data): InternalTransaction
    {
        return InternalTransaction::create([
            'unit_id' => $data['unit_id'],
            'resort_id' => $data['resort_id'] ?? null,
            'category' => $data['category'] ?? 'EXPENSE_UNIT',
            'description' => $data['description'] ?? null,
            'transaction_date' => Carbon::now(),
            'approved_amount' => (float) $data['amount'],
            'status' => 'pending',
            'created_by' => $data['created_by'] ?? null,
        ]);
    }

    /**
     * Approve expense request → status = approved (counted in reports)
     */
    public function approve(int $id, ?int $approvedBy = null): InternalTransaction
    {
        $tx = InternalTransaction::findOrFail($id);
        $tx->update(['status' => 'approved', 'approved_by' => $approvedBy]);
        return $tx->fresh();
    }

    /**
     * Reject expense request → status = rejected
     */
    public function reject(int $id, ?string $reason = null, ?int $rejectedBy = null): InternalTransaction
    {
        $tx = InternalTransaction::findOrFail($id);
        $tx->update([
            'status' => 'rejected',
            'notes' => $reason,
            'approved_by' => $rejectedBy,
        ]);
        return $tx->fresh();
    }
}
```

- [ ] **Step 2: Create ExpenseRequestController**

```php
<?php

namespace App\Http\Controllers;

use App\Services\ExpenseRequestService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class ExpenseRequestController extends Controller
{
    public function __construct(private ExpenseRequestService $service) {}

    /**
     * GET /api/report/expense-requests
     */
    public function index(Request $request): JsonResponse
    {
        $filters = [
            'status' => $request->get('status'),
            'unit_id' => $request->get('unit_id'),
        ];
        $requests = $this->service->getRequests($filters);
        return $this->sendResponse(['requests' => $requests]);
    }

    /**
     * POST /api/report/expense-requests
     * Staff creates expense request (status = pending)
     */
    public function store(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'unit_id' => 'required|integer|exists:units,id',
            'resort_id' => 'nullable|integer|exists:resorts,id',
            'category' => 'nullable|string',
            'amount' => 'required|numeric|min:1000',
            'description' => 'nullable|string|max:500',
        ]);

        $tx = $this->service->createRequest([
            ...$validated,
            'created_by' => $request->user()?->id,
        ]);

        return $this->sendResponse(['request' => $tx], 'Expense request submitted', 201);
    }

    /**
     * POST /api/report/expense-requests/{id}/approve
     */
    public function approve(Request $request, int $id): JsonResponse
    {
        $tx = $this->service->approve($id, $request->user()?->id);
        return $this->sendResponse(['request' => $tx], 'Expense request approved');
    }

    /**
     * POST /api/report/expense-requests/{id}/reject
     */
    public function reject(Request $request, int $id): JsonResponse
    {
        $validated = $request->validate([
            'reason' => 'nullable|string|max:500',
        ]);
        $tx = $this->service->reject($id, $validated['reason'] ?? null, $request->user()?->id);
        return $this->sendResponse(['request' => $tx], 'Expense request rejected');
    }
}
```

- [ ] **Step 3: Add expense request routes** — add after budget routes:
```php
Route::prefix('report/expense-requests')->group(function () {
    Route::get('/', [ExpenseRequestController::class, 'index']);
    Route::post('/', [ExpenseRequestController::class, 'store']);
    Route::post('/{id}/approve', [ExpenseRequestController::class, 'approve']);
    Route::post('/{id}/reject', [ExpenseRequestController::class, 'reject']);
});
```

- [ ] **Step 4: Commit**
```bash
git add app/Services/ExpenseRequestService.php app/Http/Controllers/ExpenseRequestController.php routes/api.php
git commit -m "feat: add expense request flow via internal_transactions"
```

---

### Task 4: Update InvestorReportServiceImpl — include budget + filter by expense status

**Files:**
- Modify: `app/Services/InvestorReportServiceImpl.php`

- [ ] **Step 1: Update getExpenseSummary to only count approved transactions**

In `getExpenseSummary()` method, the `category` loop already queries `internal_transactions`. Add `where('status', 'approved')` to each query. Check the current implementation — if it already has `where('status', 'approved')`, confirm it; otherwise add it. Also add budget lookup to `getIncomeSummary` or add a new `getBudgetSummary` private method.

Add `getBudgetSummary` private method and include in `getOverview` return.

Current `getExpenseSummary` already has `where('status', 'approved')` (lines 280-281), so the expense calculation is already correct — only approved transactions are counted. Add budget inclusion.

```php
// Add to getOverview() return array:
'budget' => $this->getBudgetSummary($startDate, $endDate, $unitId, $resortId),
```

Add `getBudgetSummary` private method:
```php
private function getBudgetSummary(Carbon $startDate, Carbon $endDate, ?string $unitId, ?string $resortId): array
{
    $period = $startDate->format('Y-m');

    $budgetQuery = DB::table('budgets')
        ->join('units', 'budgets.unit_id', '=', 'units.id')
        ->where('budgets.period', $period);

    if ($unitId && $unitId !== 'all') {
        $budgetQuery->where('budgets.unit_id', $unitId);
    }

    $budgets = $budgetQuery->select(
        'budgets.unit_id',
        'units.name as unit_name',
        'budgets.amount'
    )->get();

    // Get actual approved expense per unit for the period
    $expenseQuery = DB::table('internal_transactions')
        ->whereBetween('transaction_date', [$startDate, $endDate])
        ->where('status', 'approved')
        ->whereIn('category', ['EXPENSE_UNIT', 'EXPENSE_RESORT', 'TRANSPORT', 'SALARY'])
        ->select('unit_id', DB::raw('SUM(approved_amount) as total_actual'))
        ->groupBy('unit_id');

    if ($unitId && $unitId !== 'all') {
        $expenseQuery->where('unit_id', $unitId);
    }

    $actualByUnit = $expenseQuery->get()->keyBy('unit_id');

    $budgetList = $budgets->map(fn($b) => [
        'unit_id' => $b->unit_id,
        'unit_name' => $b->unit_name,
        'budget' => (double) $b->amount,
        'actual' => (double) ($actualByUnit->get($b->unit_id)?->total_actual ?? 0),
        'remaining' => (double) $b->amount - (double) ($actualByUnit->get($b->unit_id)?->total_actual ?? 0),
    ])->values();

    $totalBudget = $budgets->sum('amount');
    $totalActual = collect($budgetList)->sum('actual');

    return [
        'period' => $period,
        'by_unit' => $budgetList,
        'total_budget' => $totalBudget,
        'total_actual' => $totalActual,
        'total_remaining' => $totalBudget - $totalActual,
    ];
}
```

- [ ] **Step 2: Commit**
```bash
git add app/Services/InvestorReportServiceImpl.php
git commit -m "feat: add budget summary to investor report overview"
```

---

## Flutter Tasks

### Task 5: Budget model + BudgetStore

**Files:**
- Create: `lib/models/budget.dart`
- Create: `lib/models/budget.mapper.dart`
- Create: `lib/store/BudgetStore.dart`
- Create: `lib/store/BudgetStore.g.dart`

- [ ] **Step 1: Create budget.dart model**

```dart
import 'package:dart_mappable/dart_mappable.dart';
import 'decimal.dart';

part 'budget.mapper.dart';

@MappableClass(includeCustomMappers: [DecimalMapper()])
class BudgetSummary with BudgetSummaryMappable {
  String period;
  List<BudgetByUnit> byUnit;
  double totalBudget;
  double totalActual;
  double totalRemaining;

  BudgetSummary({
    required this.period,
    required this.byUnit,
    required this.totalBudget,
    required this.totalActual,
    required this.totalRemaining,
  });
}

@MappableClass(includeCustomMappers: [DecimalMapper()])
class BudgetByUnit with BudgetByUnitMappable {
  String unitId;
  String unitName;
  double budget;
  double actual;
  double remaining;

  BudgetByUnit({
    required this.unitId,
    required this.unitName,
    required this.budget,
    required this.actual,
    required this.remaining,
  });
}
```

- [ ] **Step 2: Generate budget.mapper.dart** (manual write since no build_runner):
Copy the structure from `investor_report.mapper.dart` pattern. Create BudgetSummaryMapper and BudgetByUnitMapper following the same ClassMapperBase pattern.

- [ ] **Step 3: Create BudgetStore**

```dart
import 'package:mobx/mobx.dart';
import 'package:ksu_mobile_app/api/OfficeService.dart';
import 'package:ksu_mobile_app/models/budget.dart';

part 'BudgetStore.g.dart';

class BudgetStore extends _BudgetStore with _$BudgetStore {
  static final BudgetStore _singleton = BudgetStore._internal();
  factory BudgetStore() => _singleton;
  BudgetStore._internal() : super._();
}

abstract class _BudgetStore with Store {
  final OfficeService _officeService = OfficeService();

  @observable bool isLoading = false;
  @observable BudgetSummary? budgetSummary;
  @observable String? errorMessage;

  @action
  Future<void> loadBudgetSummary(String period) async {
    try {
      isLoading = true;
      errorMessage = null;
      final resp = await _officeService.get('/report/budgets?period=$period');
      // Response: { period, budgets: [...] } — map to BudgetSummary
      // For now, parse directly if API returns budget data
      isLoading = false;
    } catch (e) {
      errorMessage = e.toString();
      isLoading = false;
    }
  }

  @action
  Future<void> saveBudget(String unitId, String period, double amount) async {
    try {
      await _officeService.post('/report/budgets', {
        'unit_id': unitId,
        'period': period,
        'amount': amount,
      });
      await loadBudgetSummary(period);
    } catch (e) {
      errorMessage = e.toString();
    }
  }
}
```

- [ ] **Step 4: Generate BudgetStore.g.dart** using build_runner or manual write following InvestorReportStore.g.dart pattern.

- [ ] **Step 5: Commit**
```bash
git add lib/models/budget.dart lib/models/budget.mapper.dart lib/store/BudgetStore.dart lib/store/BudgetStore.g.dart
git commit -m "feat: add Budget model and BudgetStore"
```

---

### Task 6: ExpenseRequestStore + ReportService update

**Files:**
- Modify: `lib/api/InvestorReportService.dart` (add budget endpoint)
- Create: `lib/store/ExpenseRequestStore.dart`
- Create: `lib/store/ExpenseRequestStore.g.dart`

- [ ] **Step 1: Create ExpenseRequestStore** following MobX pattern from InvestorReportStore. Include: list pending requests, create request, approve, reject actions. Note: approve/reject only for admin role.

- [ ] **Step 2: Update InvestorReportService** — add `getBudgetSummary` method.

- [ ] **Step 3: Commit**
```bash
git add lib/api/InvestorReportService.dart lib/store/ExpenseRequestStore.dart lib/store/ExpenseRequestStore.g.dart
git commit -m "feat: add ExpenseRequestStore and budget API to InvestorReportService"
```

---

### Task 7: Shared Report Widgets

**Files:**
- Create: `lib/widgets/report/report_filter_panel.dart`
- Create: `lib/widgets/report/report_metric_card.dart`
- Create: `lib/widgets/report/report_line_item.dart`

Extract common widgets from existing InvestorReportScreen that will be reused across tabs. Keep them as simple StatelessWidget wrappers around existing code.

- [ ] **Step 1: Commit**
```bash
git add lib/widgets/report/
git commit -m "feat: extract shared report widgets"
```

---

### Task 8: Split InvestorReportScreen into Shell + Tabs

**Files:**
- Create: `lib/screen/report/tabs/kas_tab.dart`
- Create: `lib/screen/report/tabs/profit_tab.dart`
- Create: `lib/screen/report/tabs/loan_condition_tab.dart`
- Create: `lib/screen/report/tabs/budget_tab.dart`
- Modify: `lib/screen/report/investor_report_screen.dart`

- [ ] **Step 1: Create kas_tab.dart** — extract `_buildModernOverviewTab()` from current InvestorReportScreen into a new file. Add swipe support via TabBarView.

- [ ] **Step 2: Create profit_tab.dart** — extract `_buildModernProfitTab()`

- [ ] **Step 3: Create loan_condition_tab.dart** — extract `_buildModernLoanConditionTab()`

- [ ] **Step 4: Create budget_tab.dart** — rewrite `_buildModernBudgetTab()` with: unit budget list (from budget summary), pending expense requests list (for admin), form to create expense request. Include "Set Budget" button for admin.

- [ ] **Step 5: Refactor investor_report_screen.dart** — keep as shell with:
  - Tab bar (horizontal, swipe enabled via TabBarView)
  - TabController initialization
  - Filter panel (shared)
  - Pass data/Callbacks to each tab
  - Import tab screens from `report/tabs/`

The key changes to InvestorReportScreen:
```dart
// Replace IndexedStack with TabBarView for swipe
TabBarView(
  controller: _tabController,
  children: [
    KasTab(store: _store, ...),
    ProfitTab(store: _store, ...),
    LoanConditionTab(store: _store, ...),
    BudgetTab(store: _store, budgetStore: _budgetStore, expenseStore: _expenseStore, ...),
  ],
)
```

- [ ] **Step 6: Commit**
```bash
git add lib/screen/report/investor_report_screen.dart lib/screen/report/tabs/
git commit -m "refactor: split InvestorReportScreen into modular tabs with swipe support"
```

---

### Task 9: Budget Tab — unit budget list + set budget modal

**Files:**
- Modify: `lib/screen/report/tabs/budget_tab.dart`

- [ ] **Step 1: Add unit budget list with progress bars** — for each unit in `budgetSummary.byUnit`, show: unit name, budget amount, actual spent, remaining, progress bar. Use green/red color based on over/under budget.

- [ ] **Step 2: Add "Set Budget" modal (admin only)** — button opens bottom sheet / dialog with unit dropdown + amount input. Submit calls `budgetStore.saveBudget()`.

- [ ] **Step 3: Add "Ajukan Pengeluaran" button** — opens form: amount, category (EXPENSE_UNIT dropdown), description. Submit calls `expenseRequestStore.createRequest()`.

- [ ] **Step 4: Add pending requests list (admin only)** — show list of pending expense requests with approve/reject buttons.

- [ ] **Step 5: Commit**
```bash
git add lib/screen/report/tabs/budget_tab.dart
git commit -m "feat: add budget management UI and expense request form to budget tab"
```

---

### Task 10: Update DashboardConfigs for role-based access

**Files:**
- Modify: `lib/screen/dashboard/DashboardConfigs.dart`

- [ ] **Step 1: Add investor_report tab to cashier config** — same as admin but budget tab has `isAdmin = false` flag passed to prevent approve actions. Staff can view all tabs but admin-only features (approve, set budget) are hidden.

- [ ] **Step 2: Commit**
```bash
git add lib/screen/dashboard/DashboardConfigs.dart
git commit -m "feat: add investor report tab to cashier dashboard"
```

---

## Spec Coverage Check

| Requirement | Task |
|---|---|
| Split InvestorReportScreen into tabs | Task 8 |
| Swipe navigation + TabBar | Task 8 |
| Budgets table (per unit per month) | Task 1 |
| Budget API (get/set) | Task 2 |
| Expense request via internal_transactions | Task 3 |
| Pending requests list + approve/reject | Task 3 |
| Budget included in overview response | Task 4 |
| Only approved expense counted in reports | Task 4 |
| Flutter Budget model + store | Task 5 |
| Flutter ExpenseRequestStore | Task 6 |
| Shared report widgets | Task 7 |
| Budget tab: unit budget list + set budget modal | Task 9 |
| Budget tab: ajukan expense + pending list | Task 9 |
| Role-based access (admin vs cashier) | Task 10 |

All requirements covered. No placeholders found.