# Employee Portal — Technical Reference

Namespace: `App\Http\Controllers\Portal`
Route file: `routes/portal.php`
Route prefix: `/portal` — named prefix `portal.`

---

## Routes

### Guest Routes (middleware: `guest:employee`)

| Method | URI | Controller@Method | Route Name |
|--------|-----|-------------------|------------|
| GET/HEAD | `/portal/login` | `Auth\AuthenticatedSessionController@create` | `portal.login` |
| POST | `/portal/login` | `Auth\AuthenticatedSessionController@store` | `portal.login.store` |

### Authenticated Routes (middleware: `auth:employee`, `employee.active`)

| Method | URI | Controller@Method | Route Name |
|--------|-----|-------------------|------------|
| GET/HEAD | `/portal/` | `DashboardController@index` | `portal.dashboard` |
| POST | `/portal/logout` | `Auth\AuthenticatedSessionController@destroy` | `portal.logout` |
| GET/HEAD | `/portal/profile` | `ProfileController@index` | `portal.profile` |
| GET/HEAD | `/portal/requests` | `RequestController@index` | `portal.requests` |
| GET/HEAD | `/portal/requests/{requestType}/{requestId}` | `RequestController@show` | `portal.requests.show` |
| GET/HEAD | `/portal/attendance` | `AttendanceController@index` | `portal.attendance` |
| GET/HEAD | `/portal/payroll` | `PayrollController@index` | `portal.payroll` |
| GET/HEAD | `/portal/payroll/{lineId}` | `PayrollController@show` | `portal.payroll.show` |
| GET/HEAD | `/portal/submit/{type}` | `SubmissionController@create` | `portal.submit.create` |
| POST | `/portal/submit/{type}` | `SubmissionController@store` | `portal.submit.store` |
| GET/HEAD | `/portal/tasks` | `TaskController@index` | `portal.tasks` |
| GET/HEAD | `/portal/tasks/{taskId}` | `TaskController@show` | `portal.tasks.show` |
| GET/HEAD | `/portal/complaints` | `PortalPageController@show` (page=complaints) | `portal.complaints` |
| GET/HEAD | `/portal/resignation` | `PortalPageController@show` (page=resignation) | `portal.resignation` |
| GET/HEAD | `/portal/documents` | `DocumentController@index` | `portal.documents` |
| GET/HEAD | `/portal/notifications` | `NotificationController@index` | `portal.notifications` |
| POST | `/portal/notifications/{id}/read` | `NotificationController@markRead` | `portal.notifications.read` |
| POST | `/portal/notifications/read-all` | `NotificationController@markAllRead` | `portal.notifications.read-all` |

---

## Employee Auth Guard

### Configuration (`config/auth.php`)

```php
'guards' => [
    'employee' => [
        'driver' => 'session',
        'provider' => 'employees',
    ],
],

'providers' => [
    'employees' => [
        'driver' => 'eloquent',
        'model' => App\Models\HR\Employee\Employee::class,
    ],
],

'passwords' => [
    'employees' => [
        'provider' => 'employees',
        'table' => 'password_reset_tokens',
        'expire' => 60,
        'throttle' => 60,
    ],
],
```

The `employee` guard uses Laravel's standard session driver. It is backed by the `Employee` Eloquent model. This guard is entirely separate from the `admin` guard — sessions are not shared and credentials are not interchangeable. Calling `auth('employee')->user()` inside portal controllers returns the authenticated `Employee` model instance.

### Middleware: `EnsureEmployeeActive`

**Class:** `App\Http\Middleware\EnsureEmployeeActive`
**Alias registered as:** `employee.active`

```php
public function handle(Request $request, Closure $next): Response
{
    $employee = Auth::guard('employee')->user();

    if ($employee && ! $employee->is_active) {
        Auth::guard('employee')->logout();
        $request->session()->invalidate();
        $request->session()->regenerateToken();

        return redirect()->route('portal.login')
            ->with('error', trans('portal.auth.inactive'));
    }

    return $next($request);
}
```

Logic:
1. Resolve the currently authenticated employee from the `employee` guard.
2. If an employee is authenticated but their `is_active` field is `false`:
   - Log the employee out of the `employee` guard.
   - Invalidate the session entirely.
   - Regenerate the CSRF token.
   - Redirect to the portal login page with a translated error message.
3. Otherwise pass the request through unchanged.

This middleware runs on every authenticated portal route, so deactivating an employee in the admin panel immediately denies them portal access on their next request — even if they have an active session.

---

## Middleware Stack: Portal vs Admin

| Concern | Admin routes | Portal routes |
|---------|-------------|---------------|
| Locale prefix | Yes — `/{locale}/admin/...` via `mcamara/laravel-localization` group | No locale prefix — `/portal/...` |
| Auth guard | `auth:admin` (Admin model) | `auth:employee` (Employee model) |
| Active check | None | `employee.active` (EnsureEmployeeActive) |
| Permission system | Spatie Laravel Permission via `permission:` middleware | Not used — employee sees only their own data |
| Guest redirect | `guest:admin` on auth routes | `guest:employee` on login routes |

The portal routes are defined in a flat `Route::prefix('portal')` group in `routes/portal.php`, completely outside the localization wrapper that wraps all admin routes.

---

## Controllers

### `Auth\AuthenticatedSessionController`

**Full class:** `App\Http\Controllers\Portal\Auth\AuthenticatedSessionController`

#### `create(): View|RedirectResponse`

- **Route:** GET `/portal/login`
- **Inputs:** None.
- **Logic:**
  1. Check if already authenticated: `Auth::guard('employee')->check()`.
  2. If yes, redirect to `portal.dashboard`.
  3. If no, return view `portal.auth.login`.
- **Returns:** Blade view or redirect.

#### `store(LoginRequest $request): RedirectResponse`

- **Route:** POST `/portal/login`
- **Inputs:** Validated by `App\Http\Requests\Portal\Auth\LoginRequest` (contains `authenticate()` which calls `Auth::guard('employee')->attempt()`).
- **Logic:**
  1. Call `$request->authenticate()` — this attempts login against the `employee` guard; throws a validation exception on failure.
  2. Regenerate the session id to prevent session fixation.
  3. Redirect to the intended URL (the page the user was trying to reach before being redirected to login), defaulting to `portal.dashboard`.
- **Returns:** Redirect.

#### `destroy(Request $request): RedirectResponse`

- **Route:** POST `/portal/logout`
- **Logic:**
  1. `Auth::guard('employee')->logout()`.
  2. `$request->session()->invalidate()`.
  3. `$request->session()->regenerateToken()`.
  4. Redirect to `portal.login`.
- **Returns:** Redirect.

---

### `DashboardController`

**Dependencies:** `App\Services\Portal\NotificationPortalService` (injected via constructor).

#### `index(): View`

- **Route:** GET `/portal/`
- **Inputs:** None.
- **Logic:**
  1. Resolve authenticated employee: `auth('employee')->user()`.
  2. Call `$this->notificationService->getUnreadCount($employee)`.
  3. Call `$this->notificationService->getRecent($employee, 5)` — last 5 notifications.
  4. Call `$this->notificationService->getPendingRequests($employee)` — outstanding HR requests awaiting approval.
  5. Pass all data to view `portal.dashboard`.
- **Returns:** View with keys: `pageTitle`, `pageDescription`, `unreadCount`, `recentNotifications`, `pendingRequests`, `notificationService`.

---

### `ProfileController`

No service injection — queries directly on the Employee model's relationships.

#### `index(): View`

- **Route:** GET `/portal/profile`
- **Inputs:** None.
- **Logic:**
  1. Resolve employee: `auth('employee')->user()->load(['branch','department','designation','grade','directManager'])`.
  2. Query `$employee->contracts()->with('contractType')->orderByDesc('is_current')->orderByDesc('start_date')->first()`.
  3. Query `$employee->salaryProfiles()->with('costCenter')->orderByDesc('is_current')->orderByDesc('effective_from')->first()`.
  4. Query `$employee->bankAccounts()->orderByDesc('is_primary')->orderByDesc('is_active')->latest('id')->first()`.
  5. Query `$employee->documents()->where('is_active', true)->latest('expiry_date')->latest('id')->limit(5)->get()`.
  6. Pass all to view `portal.profile.index`.
- **Returns:** View with keys: `pageTitle`, `pageDescription`, `employee`, `currentContract`, `currentSalaryProfile`, `primaryBankAccount`, `documents`.

---

### `AttendanceController`

**Dependencies:** `App\Services\Portal\AttendancePortalService`.

#### `index(Request $request): View`

- **Route:** GET `/portal/attendance`
- **Inputs:** `year` (int, default: current year), `month` (int, default: current month) — both from query string.
- **Logic:**
  1. Resolve employee.
  2. Read `year` and `month` from request, falling back to `now()->year` / `now()->month`.
  3. Call `$this->service->getStats($employee, $year, $month)` — summary counts (present, absent, late, overtime, etc.).
  4. Call `$this->service->paginate($employee, $year, $month)` — paginated attendance records.
  5. Call `$this->service->getMonthOptions()` — array for the month filter dropdown.
  6. Pass all to view `portal.attendance.index`.
- **Returns:** View with keys: `pageTitle`, `pageDescription`, `year`, `month`, `stats`, `records`, `monthOptions`, `service`.

---

### `PayrollController`

**Dependencies:** `App\Services\Portal\PayrollPortalService`.

#### `index(): View`

- **Route:** GET `/portal/payroll`
- **Inputs:** None.
- **Logic:**
  1. Resolve employee.
  2. Call `$this->service->getLatestLine($employee)` — most recent payroll line.
  3. Call `$this->service->paginate($employee)` — full paginated payroll history.
  4. Call `$this->service->getLeaveUsage($employee)` — leave balance/usage summary.
  5. Pass to view `portal.payroll.index`.
- **Returns:** View with keys: `pageTitle`, `pageDescription`, `latestLine`, `payrollLines`, `leaveUsage`.

#### `show(int $lineId): View`

- **Route:** GET `/portal/payroll/{lineId}`
- **Inputs:** `lineId` — integer route parameter.
- **Logic:**
  1. Resolve employee.
  2. Call `$this->service->find($employee, $lineId)` — returns a payroll line only if it belongs to this employee, or `null`.
  3. If null: `abort(404)`.
  4. Pass to view `portal.payroll.show`.
- **Returns:** View with keys: `pageTitle`, `pageDescription`, `line`.

---

### `RequestController`

**Dependencies:** `App\Services\Portal\PortalRequestService`.

#### `index(RequestIndexRequest $request): View`

- **Route:** GET `/portal/requests`
- **Inputs:** Query params validated by `App\Http\Requests\Portal\RequestIndexRequest`:
  - `request_type` (optional)
  - `status` (optional)
- **Logic:**
  1. Resolve employee.
  2. Extract `$filters = $request->safe()->only(['request_type', 'status'])`.
  3. Call `$this->portalRequestService->getRequestStats($employee)` — counts per status.
  4. Call `$this->portalRequestService->paginateForEmployee($employee, $filters)` — paginated filtered list.
  5. Call `$this->portalRequestService->getFilterOptions()` — available type/status options for the filter UI; spread into view data.
  6. Pass to view `portal.requests.index`.
- **Returns:** View with keys: `pageTitle`, `pageDescription`, `filters`, `requestStats`, `requestItems`, plus whatever keys `getFilterOptions()` returns (spread with `...`).

#### `show(string $requestType, int $requestId): View`

- **Route:** GET `/portal/requests/{requestType}/{requestId}`
- **Inputs:** `requestType` (string route segment), `requestId` (int route segment).
- **Logic:**
  1. Call `$this->portalRequestService->findForEmployee(auth('employee')->user(), $requestType, $requestId)`.
  2. If null: `abort(404)`.
  3. Pass to view `portal.requests.show`.
- **Returns:** View with keys: `pageTitle`, `pageDescription`, `requestItem`.

---

### `SubmissionController`

**Dependencies:** `App\Services\Portal\SubmissionService`.

**Allowed types** (validated on every request):
```php
private array $allowedTypes = [
    'leave', 'permission', 'loan', 'data_change', 'complaint', 'resignation',
];
```

**Form request class map:**
```php
private array $formRequestMap = [
    'leave'       => SubmitLeaveRequest::class,
    'permission'  => SubmitPermissionRequest::class,
    'loan'        => SubmitLoanRequest::class,
    'data_change' => SubmitDataChangeRequest::class,
    'complaint'   => SubmitComplaintRequest::class,
    'resignation' => SubmitResignationRequest::class,
];
```

#### `create(string $type): View`

- **Route:** GET `/portal/submit/{type}`
- **Inputs:** `type` — URL segment matching one of the allowed types.
- **Logic:**
  1. `abort_unless(in_array($type, $this->allowedTypes, true), 404)` — guard against unknown types.
  2. Resolve employee.
  3. Call `$this->service->getFormData($type, $employee)` — returns contextual data for the form (e.g. leave types, current balance). Merged into view data.
  4. Return view `portal.submit.{$type}` — one blade file per type.
- **Returns:** View with keys: `pageTitle`, `pageDescription`, `type`, plus whatever `getFormData` returns.

#### `store(string $type, Request $request): RedirectResponse`

- **Route:** POST `/portal/submit/{type}`
- **Inputs:** `type` — URL segment. Form body varies by type. `attachments` — optional array of uploaded files.
- **Logic:**
  1. `abort_unless(in_array($type, $this->allowedTypes, true), 404)`.
  2. Look up the correct form request class from `$formRequestMap[$type]`.
  3. Instantiate it from the current request: `$formRequestClass::createFrom($request)`.
  4. Bind container and redirector, then call `$formRequest->validateResolved()` — this triggers Laravel's form request validation pipeline. Validation failure redirects back with errors per normal Laravel behaviour.
  5. Extract `$validated = $formRequest->validated()`.
  6. Call `$this->service->submit($type, $employee, $validated, $request->file('attachments', []))`.
  7. Redirect to `portal.requests` with a success flash message.
- **Returns:** Redirect to request list on success.

---

### `TaskController`

**Dependencies:** `App\Services\Portal\TaskPortalService`.

#### `index(Request $request): View`

- **Route:** GET `/portal/tasks`
- **Inputs:** `status` (string, optional) — query string filter.
- **Logic:**
  1. Resolve employee.
  2. Read `$status = $request->input('status')`.
  3. Call `$this->service->getStats($employee)` — counts by status.
  4. Call `$this->service->paginate($employee, $status)` — paginated task list, scoped to employee.
  5. Pass to view `portal.tasks.index`.
- **Returns:** View with keys: `pageTitle`, `pageDescription`, `stats`, `tasks`, `statusFilter`, `service`.

#### `show(int $taskId): View`

- **Route:** GET `/portal/tasks/{taskId}`
- **Inputs:** `taskId` — integer route parameter.
- **Logic:**
  1. Resolve employee.
  2. Call `$this->service->find($employee, $taskId)` — returns task only if assigned to this employee, else `null`.
  3. If null: `abort(404)`.
  4. Pass to view `portal.tasks.show`.
- **Returns:** View with keys: `pageTitle`, `pageDescription`, `task`, `service`.

---

### `DocumentController`

**Dependencies:** `App\Services\Portal\DocumentPortalService`.

#### `index(Request $request): View`

- **Route:** GET `/portal/documents`
- **Inputs:** `type` (string, optional) — query string filter for document type.
- **Logic:**
  1. Resolve employee.
  2. Read `$type = $request->input('type')`.
  3. Call `$this->service->paginate($employee, $type)` — paginated document list scoped to employee, filtered by type if provided.
  4. Call `$this->service->getDocumentTypes($employee)` — list of distinct document types the employee has, for the filter dropdown.
  5. Pass to view `portal.documents.index`.
- **Returns:** View with keys: `pageTitle`, `pageDescription`, `documents`, `documentTypes`, `typeFilter`, `service`.

---

### `NotificationController`

**Dependencies:** `App\Services\Portal\NotificationPortalService`.

#### `index(): View`

- **Route:** GET `/portal/notifications`
- **Inputs:** None.
- **Logic:**
  1. Resolve employee.
  2. Call `$this->service->paginate($employee)` — all notifications paginated.
  3. Call `$this->service->getPendingRequests($employee)` — pending HR requests for context panel.
  4. Pass to view `portal.notifications.index`.
- **Returns:** View with keys: `pageTitle`, `pageDescription`, `notifications`, `pendingRequests`, `service`.

#### `markRead(string $id): RedirectResponse`

- **Route:** POST `/portal/notifications/{id}/read`
- **Inputs:** `id` — notification id from URL.
- **Logic:** Call `$this->service->markAsRead(auth('employee')->user(), $id)`. Redirect back.
- **Returns:** Redirect back.

#### `markAllRead(): RedirectResponse`

- **Route:** POST `/portal/notifications/read-all`
- **Inputs:** None.
- **Logic:** Call `$this->service->markAllAsRead(auth('employee')->user())`. Redirect back with success flash.
- **Returns:** Redirect back with `success` flash message.

---

### `PortalPageController`

A generic static-page controller. No service dependencies.

#### `show(string $page): View`

- **Routes:** GET `/portal/complaints` (defaults: `page=complaints`) and GET `/portal/resignation` (defaults: `page=resignation`). The `$page` value is injected by the route's `->defaults('page', '...')` binding.
- **Supported page keys:** `attendance`, `payroll`, `tasks`, `complaints`, `resignation`, `documents`.
- **Logic:**
  1. Look up `$pages[$page]` in a hardcoded array of `[title, description]` pairs.
  2. `abort_unless(isset($pages[$page]), 404)`.
  3. Return view `portal.pages.placeholder` with `pageKey`, `pageTitle`, `pageDescription`.
- **Returns:** Shared placeholder Blade view.

---

## Portal Services vs HR/Sales Services

All portal data access goes through dedicated portal service classes located in `app/Services/Portal/`. These services read from the same underlying models as the admin-side HR services but do not reuse admin service classes directly.

| Portal Service | Underlying Data | Reuse Pattern |
|----------------|----------------|---------------|
| `AttendancePortalService` | Attendance records, overtime, absences | Queries HR models directly; does not call admin `AttendanceService` |
| `PayrollPortalService` | Payroll runs and lines, leave balances | Queries payroll and leave models directly |
| `PortalRequestService` | Leave, loan, permission, bonus, complaint, resignation, data-change request models | Aggregates across multiple request types; shared models with admin HR |
| `SubmissionService` | Creates HR request records | Writes to the same request tables the admin HR module reads; enters the same workflow |
| `TaskPortalService` | Task and task-comment models | Reads admin-created tasks scoped to the employee |
| `DocumentPortalService` | Employee document model | Reads same `employee_documents` table as admin HR |
| `NotificationPortalService` | Laravel notifications on the Employee model | Standard Laravel notification infrastructure |

The separation means portal services can enforce employee-scoped queries (always filtering by the authenticated employee's id) without coupling them to admin business logic that has no access control equivalent for the portal.

`SubmissionService::submit()` is the one place where the portal writes back into HR workflow records. It creates the appropriate request model and triggers the same multi-step approval workflow (`RequestWorkflow`, `Approval` models) that the admin HR module uses.
