# Staff Mobile API Guide

## Base URL and authentication

| Environment | Base URL |
|---|---|
| **Live** | **`https://odyssey-crm.dextragroups.com`** |
| Local testing | `http://127.0.0.1:8000` (same computer) or the computer's LAN IP, e.g. `http://192.168.1.10:8000` (phone on the same Wi-Fi) |

All endpoints in this guide are under `/api/staff` — e.g. `https://odyssey-crm.dextragroups.com/api/staff/login`. File links in responses (photos, signatures, PDFs) are full URLs on the same host.

### Login

`POST /api/staff/login` — `application/json`; no token required.

```json
{
  "login": "staff email or phone",
  "password": "staff password",
  "device_name": "mobile-app"
}
```

`device_name` is optional. A successful response contains a `token` and user details:

```json
{
  "token": "1|returned-plain-text-token",
  "user": {
    "id": 123,
    "name": "Staff Name",
    "email": "staff@example.com",
    "phone": "9876543210",
    "designation": "Technician",
    "roles": ["technician"],
    "is_technical_head": false
  }
}
```

Store the returned token securely in the mobile app. Send it with every protected request:

```http
Authorization: Bearer <token>
Accept: application/json
```

Login creates one staff token for the device; the same token is used for all `/api/staff/*` protected endpoints. It is different from a customer/client token.

### Logout

`POST /api/staff/logout` — requires the bearer token. This revokes the current token. The app should discard the stored token after a successful logout and log in again to obtain another.

### Common responses

- `401 Unauthorized`: missing, invalid, expired, or revoked token; log in again.
- `403 Forbidden`: authenticated user does not have the required role or task assignment.
- `422 Unprocessable Entity`: invalid fields or an operation that cannot be performed (for example, a duplicate check-in or invalid task transition). The response contains validation/error details.

## Endpoint overview

**Who:** T = field technician (technician, site supervisor, installation engineer, electrician, helper) · L = Technical Lead (`technical_manager`) · H = Technical Head · All = every staff login. Use `user.roles` from login/`me` to decide which screens to show.

| # | API | Method | Path | Who |
|---|---|---|---|---|
| **Account** |||||
| A1 | Login | `POST` | `/api/staff/login` | All |
| A2 | Logout | `POST` | `/api/staff/logout` | All |
| A3 | My profile | `GET` | `/api/staff/me` | All |
| A4 | Change password | `POST` | `/api/staff/change-password` | All |
| **Dashboard & notifications** |||||
| 5 | Dashboard Summary | `GET` | `/api/staff/dashboard` | All |
| N1 | Notifications list | `GET` | `/api/staff/notifications` | All |
| N2 | Unread count | `GET` | `/api/staff/notifications/unread-count` | All |
| N3 | Mark one read | `POST` | `/api/staff/notifications/{id}/read` | All |
| N4 | Mark all read | `POST` | `/api/staff/notifications/read-all` | All |
| **Attendance** |||||
| 1 | Check-In | `POST` | `/api/staff/attendance/check-in` | T, L, H |
| 2 | Check-Out | `POST` | `/api/staff/attendance/check-out` | T, L, H |
| 3 | Attendance Status | `GET` | `/api/staff/attendance/status` | T, L, H |
| 4 | Attendance History | `GET` | `/api/staff/attendance/history` | T, L, H |
| **Tasks (work assigned to me)** |||||
| 6 | Task List | `GET` | `/api/staff/tasks` | T |
| 7 | Task Details | `GET` | `/api/staff/tasks/{task}` | T |
| 8 | Task Start | `POST` | `/api/staff/tasks/{task}/start` | T |
| 9 | Task Status Update | `PATCH` | `/api/staff/tasks/{task}/status` | T |
| 10 | Task Completion | `POST` | `/api/staff/tasks/{task}/complete` | T |
| 11 | Delay / On-Hold | `POST` | `/api/staff/tasks/{task}/on-hold` | T |
| **Technical tickets (Callback / Repair / Upgradation / Service)** |||||
| K1 | Ticket list | `GET` | `/api/staff/tickets` | All technical |
| K2 | Ticket options (types + my projects) | `GET` | `/api/staff/tickets/options` | T |
| K3 | Raise ticket | `POST` | `/api/staff/tickets` | T |
| K4 | Ticket details + history | `GET` | `/api/staff/tickets/{ticket}` | All technical |
| K5 | Approve ticket | `POST` | `/api/staff/tickets/{ticket}/approve` | H |
| K6 | Reject ticket | `POST` | `/api/staff/tickets/{ticket}/reject` | H |
| **Expense claims** |||||
| 12 | Expense Claims list / submit | `GET` / `POST` | `/api/staff/attendance/expenses` | All technical / T, L, H submit |
| E1 | Claimable tasks + categories | `GET` | `/api/staff/attendance/expenses/tasks` | T |
| E2 | Summary by status | `GET` | `/api/staff/attendance/expenses/summary` | All technical |
| E3 | Claim details / receipt | `GET` | `/api/staff/attendance/expenses/{expense}` · `…/receipts/{n}` | Claimant, L, H |
| E4 | Lead review comment | `POST` | `/api/staff/attendance/expenses/{expense}/review` | L |
| E5 | Approve / reject | `POST` | `/api/staff/attendance/expenses/{expense}/decision` | H |
| E6 | Mark processing | `POST` | `/api/staff/attendance/expenses/{expense}/processing` | H |
| E7 | Mark reimbursed | `POST` | `/api/staff/attendance/expenses/{expense}/reimbursement` | H |
| **Site visits** |||||
| 13 | Site Visit List | `GET` | `/api/staff/site-visits` | T, L, H |
| 14 | Site Visit Details + Form | `GET` | `/api/staff/site-visits/{siteVisit}` | T, L, H |
| 15 | Site Survey Questions | `GET` | `/api/staff/site-visit-questions` | T |
| 16 | Start Site Visit | `POST` | `/api/staff/site-visits/{siteVisit}/start` | T |
| 17 | Submit Site Survey Report | `POST` | `/api/staff/site-visits/{siteVisit}/report` | T |
| 18 | Site Visit Assignment Queue | `GET` | `/api/staff/site-visits-queue` | L, H |
| 19 | Assign Site Visit | `POST` | `/api/staff/customers/{customer}/site-visit/assign` | L, H |
| **Pre-Installation Visit (PIV)** |||||
| P1 | PIV list | `GET` | `/api/staff/piv-visits` | T, L, H |
| P2 | PIV details + form / report | `GET` | `/api/staff/piv-visits/{piv}` | T, L, H |
| P3 | Start PIV | `POST` | `/api/staff/piv-visits/{piv}/start` | T |
| P4 | Submit PIV report | `POST` | `/api/staff/piv-visits/{piv}/report` | T |
| P5 | Assign PIV | `POST` | `/api/staff/piv-visits/{piv}/assign` | L, H |
| **Customers** |||||
| C1 | My customers | `GET` | `/api/staff/customers` | T, L, H |
| C2 | Customer details (journey, drawing, site readiness, reports) | `GET` | `/api/staff/customers/{customer}` | T, L, H |
| C3 | Daily updates (installation) | `GET` / `POST` | `/api/staff/customers/{customer}/daily-updates` | T, L, H |
| C4 | Delete my daily update | `DELETE` | `/api/staff/customers/{customer}/daily-updates/{id}` | T, L, H |
| **Projects (installation)** |||||
| J1 | Projects list / details | `GET` | `/api/staff/projects` · `/api/staff/projects/{project}` | T, L, H |
| J2 | Team add / remove | `POST` / `DELETE` | `/api/staff/projects/{project}/team` | L, H |
| J3 | Installation progress, material, testing | `POST` | `/api/staff/projects/{project}/installation-progress` · `material-consumption` · `testing` | T, L, H |
| J4 | Handover, certificate | `POST` | `/api/staff/projects/{project}/handover` · `handover/certificate` | L, H |
| J5 | Technicians for team assignment (id, name, phone, role, active projects) | `GET` | `/api/staff/technicians` | L |
| **Report PDFs** |||||
| R1 | Site Visit Report PDF | `GET` | `/api/staff/reports/site-visits/{siteVisit}/pdf` | Whoever can see the customer |
| R2 | PIV Report PDF | `GET` | `/api/staff/reports/piv/{piv}/pdf` | Whoever can see the customer |

Replace `{task}` with the task ID, such as `/api/staff/tasks/12`. Replace `{siteVisit}` with the site visit `id` returned by the site visit list.

## Roles and access

Attendance endpoints are available to `technical_head`, `technical_manager`, and `technician`. Technician-task endpoints are available to assigned field staff: `site_supervisor`, `installation_engineer`, `electrician`, `helper`, and `technician`. Users can only read or update tasks assigned to themselves.

## Attendance

### 1. Check-In

`POST /api/staff/attendance/check-in` — `multipart/form-data`

| Field | Required | Description |
|---|---|---|
| `photo` | Yes | Camera image, maximum 10 MB |
| `task_type` | No | `project` or `site_visit`; if supplied, `task_id` is required |
| `task_id` | No | ID of a project/site visit assigned to the staff member |

The mobile app should open the device camera and upload the captured image as the `photo` file. Do not send `check_in_photo_path`; the server stores the file and writes its private path into that database column.

Returns `201` with the attendance record. Only one check-in per staff member per date is accepted.

### 2. Check-Out

`POST /api/staff/attendance/check-out` — `multipart/form-data`

| Field | Required | Description |
|---|---|---|
| `photo` | Yes | Camera image, maximum 10 MB |

Upload the camera-captured image using the `photo` key. Do not send `check_out_photo_path`; the server stores the file and writes its private path into that database column. Returns the updated attendance record. A check-in for today must exist first.

### 3. Attendance Status

`GET /api/staff/attendance/status`

Returns today's `date`, `checked_in`, `checked_out`, `can_check_in`, `can_check_out`, `attendance_complete`, and the attendance record (or `null`). The mobile app must use this endpoint as the source of truth rather than retaining yesterday's check-out state.

### 4. Attendance History

`GET /api/staff/attendance/history?from=YYYY-MM-DD&to=YYYY-MM-DD&per_page=30`

Returns the authenticated staff member's attendance history. `from` and `to` are optional; the default range is the most recent 30 calendar days. `per_page` is optional (1–100).

## Dashboard

### 5. Dashboard Summary

`GET /api/staff/dashboard`

Includes the existing site-visit and project summaries, plus:

- `attendance`: today's date, check-in/check-out flags, and timestamps.
- `tasks`: counts for open, due today, in-progress, and completed technician tasks.

## Technician Tasks

These task endpoints cover `technician_tasks` assigned to the authenticated staff member. Site visits remain available through the separate `/api/staff/site-visits` endpoints.

### 6. Task List

`GET /api/staff/tasks?status=assigned&per_page=20`

Returns paginated assigned tasks (`data` and `meta`). `status` is optional and must be a valid task status; `per_page` is optional (1–100).

### 7. Task Details

`GET /api/staff/tasks/{task}`

Returns task details, customer/project and address information, plus the task's status-update history.

### 8. Task Start

`POST /api/staff/tasks/{task}/start`

Changes an assigned task to `in_progress`. Optional body fields: `remarks`, `follow_up_date`, `follow_up_notes`, and up to five `photos` (each image up to 5 MB). Records a status update.

### 9. Task Status Update

`PATCH /api/staff/tasks/{task}/status`

Required JSON or multipart field: `status`. Accepted values are `unassigned`, `assigned`, `in_progress`, `completed`, `on_hold`, `customer_cancelled`, and `rejected`, subject to the task's allowed transitions. Optional fields: `remarks`, `reason`, `follow_up_date`, `follow_up_notes`, and up to five `photos` (each image up to 5 MB). A `reason` is required for `on_hold`, `customer_cancelled`, and `rejected`.

### 10. Task Completion

`POST /api/staff/tasks/{task}/complete`

Changes an `in_progress` task to `completed`. Optional fields: `remarks` and up to five `photos` (each image up to 5 MB). Records a status update.

### 11. Delay / On-Hold

`POST /api/staff/tasks/{task}/on-hold`

Changes an `assigned` or `in_progress` task to `on_hold`. Required field: `reason`. Optional fields: `remarks`, `follow_up_date`, `follow_up_notes`, and up to five `photos` (each image up to 5 MB). Records a status update.

Task status changes return the updated task and the recorded update. Status transitions are:

| Current status | Allowed next statuses |
|---|---|
| `assigned` | `in_progress`, `on_hold`, `customer_cancelled`, `rejected` |
| `in_progress` | `completed`, `on_hold`, `customer_cancelled`, `rejected` |
| `on_hold` | `in_progress`, `customer_cancelled`, `rejected` |

Task update photos are private. The API returns authenticated photo URLs in the update history; they use `GET /api/staff/tasks/{task}/updates/{update}/photos/{photoIndex}`.

For attendance report endpoints and photo access rules, see [STAFF_ATTENDANCE_API.md](./STAFF_ATTENDANCE_API.md).

## Expense claim APIs

Expense endpoints use the same staff bearer token. Claims are only visible to their owner, the eligible manager/technical head for that team/region, or a super admin. Staff can submit claims only for a project or site visit assigned to them.

### List expense claims

`GET /api/staff/attendance/expenses?status=submitted&category=travel&per_page=20`

All query parameters are optional. `status` must be one of `submitted`, `under_review`, `approved`, `rejected`, `processing`, or `reimbursed`. `category` must be `travel`, `food`, or `accommodation`. `per_page` defaults to 20 and allows 1–100.

**Success response — `200 OK`**

```json
{
  "data": [
    {
      "id": 701,
      "task": {
        "type": "project",
        "id": 27,
        "label": "PRJ-00027",
        "customer": {
          "id": 88,
          "name": "Example Customer"
        }
      },
      "claimant": {
        "id": 42,
        "name": "Example Technician",
        "employee_code": "TECH-0042"
      },
      "category": "travel",
      "expense_date": "2026-10-20",
      "amount": "1250.50",
      "description": "Travel to assigned installation site.",
      "status": "submitted",
      "submitted_at": "2026-10-20T14:30:00+05:30",
      "reviewed_by": null,
      "reviewed_at": null,
      "approved_by": null,
      "approved_at": null,
      "rejection_reason": null,
      "processing_at": null,
      "reimbursed_at": null,
      "reimbursement_reference": null,
      "receipts": [
        "http://localhost/api/staff/attendance/expenses/701/receipts/0"
      ],
      "reviews": []
    }
  ],
  "meta": {
    "current_page": 1,
    "last_page": 1,
    "total": 1
  }
}
```

### Submit an expense claim

`POST /api/staff/attendance/expenses` — `multipart/form-data`

The mobile app should let the user select or capture receipt files, then upload them. All fields below are required:

| Key | Type | Required | Description |
|---|---|---|---|
| `task_type` | Text | Yes | `project` or `site_visit` |
| `task_id` | Text/number | Yes | ID of the task assigned to the signed-in user |
| `category` | Text | Yes | `travel`, `food`, or `accommodation` |
| `expense_date` | Text | Yes | Date as `YYYY-MM-DD`; cannot be in the future |
| `amount` | Text/number | Yes | Positive amount with up to 2 decimal places |
| `description` | Text | Yes | Expense details, up to 5000 characters |
| `receipts[]` | File(s) | Yes | 1–5 files, JPG/JPEG/PNG/WEBP/PDF, max 10 MB each |

**Example fields**

```text
task_type = project
task_id = 27
category = travel
expense_date = 2026-10-20
amount = 1250.50
description = Travel to assigned installation site.
receipts[] = [select receipt image or PDF]
```

Do not set multipart `Content-Type` manually; let the mobile HTTP client create the boundary. The API sets the claimant and initial `submitted` status from the authenticated account.

**Success response — `201 Created`**

```json
{
  "message": "Expense claim submitted.",
  "expense": {
    "id": 701,
    "task": {
      "type": "project",
      "id": 27,
      "label": "PRJ-00027",
      "customer": {
        "id": 88,
        "name": "Example Customer"
      }
    },
    "claimant": {
      "id": 42,
      "name": "Example Technician",
      "employee_code": "TECH-0042"
    },
    "category": "travel",
    "expense_date": "2026-10-20",
    "amount": "1250.50",
    "description": "Travel to assigned installation site.",
    "status": "submitted",
    "submitted_at": "2026-10-20T14:30:00+05:30",
    "reviewed_by": null,
    "reviewed_at": null,
    "approved_by": null,
    "approved_at": null,
    "rejection_reason": null,
    "processing_at": null,
    "reimbursed_at": null,
    "reimbursement_reference": null,
    "receipts": [
      "http://localhost/api/staff/attendance/expenses/701/receipts/0"
    ],
    "reviews": []
  }
}
```

### Expense claim details

`GET /api/staff/attendance/expenses/{expense}`

Returns `{"expense": { ... }}` using the expense object shape shown above, including review comments and authenticated receipt URLs.

### View a receipt

`GET /api/staff/attendance/expenses/{expense}/receipts/{receiptIndex}`

For example, `/api/staff/attendance/expenses/701/receipts/0` downloads/displays the first receipt. The index is zero-based. The same claim access rules apply; include the bearer token.

### Add a manager review comment

`POST /api/staff/attendance/expenses/{expense}/review` — manager-only review for their direct-report team's claim.

JSON body:

```json
{
  "comment": "Receipt and travel details reviewed."
}
```

`comment` is required and must contain 3–5000 characters. This records a technical review comment and moves the claim to `under_review`; it does not approve or reject the reimbursement.

**Success response — `200 OK`**

```json
{
  "message": "Review comment recorded.",
  "expense": {
    "id": 701,
    "task": {
      "type": "project",
      "id": 27,
      "label": "PRJ-00027",
      "customer": {
        "id": 88,
        "name": "Example Customer"
      }
    },
    "claimant": {
      "id": 42,
      "name": "Example Technician",
      "employee_code": "TECH-0042"
    },
    "category": "travel",
    "expense_date": "2026-10-20",
    "amount": "1250.50",
    "description": "Travel to assigned installation site.",
    "status": "under_review",
    "submitted_at": "2026-10-20T14:30:00+05:30",
    "reviewed_by": 15,
    "reviewed_at": "2026-10-20T15:00:00+05:30",
    "approved_by": null,
    "approved_at": null,
    "rejection_reason": null,
    "processing_at": null,
    "reimbursed_at": null,
    "reimbursement_reference": null,
    "receipts": [
      "http://localhost/api/staff/attendance/expenses/701/receipts/0"
    ],
    "reviews": [
      {
        "id": 1,
        "action": "comment",
        "comment": "Receipt and travel details reviewed.",
        "reviewer": {
          "id": 15,
          "name": "Technical Manager"
        },
        "created_at": "2026-10-20T15:00:00+05:30"
      }
    ]
  }
}
```

### Expense API errors

- `401 Unauthorized`: staff bearer token is missing or invalid.
- `403 Forbidden`: role is not permitted, or the claim is outside the user's visibility.
- `404 Not Found`: the claim or receipt index does not exist.
- `422 Unprocessable Entity`: invalid request fields, task not assigned to the claimant, or claim is no longer open for review.

Example validation error:

```json
{
  "message": "The receipts field is required.",
  "errors": {
    "receipts": ["The receipts field is required."]
  }
}
```

## Complete request and response examples

Examples below use:

```text
BASE_URL = http://127.0.0.1:8000
```

Use this base URL only when Postman/the mobile app runs on the same computer as the Laravel server. On a physical phone, replace it with the computer's LAN IP or the deployed HTTPS host. Never include `/api` twice: the paths below already begin with `/api/staff`.

For protected requests, include:

```http
Authorization: Bearer <token returned by login>
Accept: application/json
```

Do not include the word `Bearer` in the token value itself if the HTTP client adds the scheme for you.

### Login request and response

**Request**

```http
POST {{BASE_URL}}/api/staff/login
Content-Type: application/json
Accept: application/json
```

```json
{
  "login": "technician@example.com",
  "password": "your-staff-password",
  "device_name": "android-app"
}
```

`login` accepts the staff member's email address or phone number. `device_name` is optional.

**Success response — `200 OK`**

```json
{
  "token": "1|example-plain-text-token",
  "user": {
    "id": 42,
    "name": "Example Technician",
    "email": "technician@example.com",
    "phone": "9876543210",
    "designation": "Technician",
    "roles": ["technician"],
    "is_technical_head": false
  }
}
```

Use the exact `token` value returned by your server. The sample token above is illustrative only. Store the token securely; never hard-code it in the app.

### Logout request and response

**Request**

```http
POST {{BASE_URL}}/api/staff/logout
Authorization: Bearer <token>
Accept: application/json
```

No request body is required.

**Success response — `200 OK`**

```json
{
  "message": "Logged out."
}
```

Logout revokes only the token used for that request. Remove that token from secure storage in the app.

## Attendance request and response examples

### Check-in

**Request**

```http
POST {{BASE_URL}}/api/staff/attendance/check-in
Authorization: Bearer <token>
Accept: application/json
Content-Type: multipart/form-data
```

Multipart form-data fields:

| Key | Type | Example | Required |
|---|---|---|---|
| `photo` | File | Image captured by the device camera | Yes |
| `task_type` | Text | `project` or `site_visit` | No |
| `task_id` | Text/number | Assigned project/site-visit ID | No; required with `task_type` |

Do not send a local path or `check_in_photo_path`. Send the actual image file in the `photo` field. The backend saves it privately and stores its path in the database.

**Success response — `201 Created`**

```json
{
  "message": "Check-in recorded.",
  "attendance": {
    "id": 501,
    "date": "2026-10-20",
    "check_in_at": "2026-10-20T08:45:00.000000Z",
    "check_in_photo_url": "http://localhost/api/staff/attendance/501/photos/check-in",
    "task": {
      "type": null,
      "id": null
    },
    "check_out_at": null,
    "check_out_photo_url": null,
    "auto_checked_out": false
  }
}
```

Photo URL host and timestamp format reflect the server configuration.

### Check-out

**Request**

```http
POST {{BASE_URL}}/api/staff/attendance/check-out
Authorization: Bearer <token>
Accept: application/json
Content-Type: multipart/form-data
```

Multipart field: `photo` (File, required). Send the captured image itself, not `check_out_photo_path`.

**Success response — `200 OK`**

```json
{
  "message": "Check-out recorded.",
  "attendance": {
    "id": 501,
    "date": "2026-10-20",
    "check_in_at": "2026-10-20T08:45:00.000000Z",
    "check_in_photo_url": "http://localhost/api/staff/attendance/501/photos/check-in",
    "task": {
      "type": null,
      "id": null
    },
    "check_out_at": "2026-10-20T17:30:00.000000Z",
    "check_out_photo_url": "http://localhost/api/staff/attendance/501/photos/check-out",
    "auto_checked_out": false
  }
}
```

### Attendance status

**Request**

```http
GET {{BASE_URL}}/api/staff/attendance/status
Authorization: Bearer <token>
Accept: application/json
```

**Success response — `200 OK`**

```json
{
  "date": "2026-10-20",
  "checked_in": true,
  "checked_out": false,
  "can_check_in": false,
  "can_check_out": true,
  "attendance_complete": false,
  "attendance": {
    "id": 501,
    "date": "2026-10-20",
    "check_in_at": "2026-10-20T08:45:00.000000Z",
    "check_in_photo_url": "http://localhost/api/staff/attendance/501/photos/check-in",
    "task": {
      "type": null,
      "id": null
    },
    "check_out_at": null,
    "check_out_photo_url": null,
    "auto_checked_out": false
  }
}
```

If there is no attendance record for today, the response is:

```json
{
  "date": "2026-10-21",
  "checked_in": false,
  "checked_out": false,
  "can_check_in": true,
  "can_check_out": false,
  "attendance_complete": false,
  "attendance": null
}
```

The mobile app should call `GET /api/staff/attendance/status` after login and whenever it opens/resumes, and refresh it when the local date changes. Render the check-in action when `can_check_in` is `true`. After check-out, `attendance_complete` is `true` for that date. On the next date, the API returns a new day's state (`can_check_in: true`) even though yesterday's completed record remains in history. Do not disable today's check-in based on a previous-day record or a locally cached `checked_out` value.

### Attendance history

**Request**

```http
GET {{BASE_URL}}/api/staff/attendance/history?from=2026-10-01&to=2026-10-20&per_page=30
Authorization: Bearer <token>
Accept: application/json
```

All query parameters are optional. By default the endpoint returns the last 30 calendar days; `per_page` must be between 1 and 100.

**Success response — `200 OK`**

```json
{
  "data": [
    {
      "id": 501,
      "date": "2026-10-20",
      "check_in_at": "2026-10-20T08:45:00.000000Z",
      "check_in_photo_url": "http://localhost/api/staff/attendance/501/photos/check-in",
      "task": {
        "type": "project",
        "id": 27
      },
      "check_out_at": "2026-10-20T17:30:00.000000Z",
      "check_out_photo_url": "http://localhost/api/staff/attendance/501/photos/check-out",
      "auto_checked_out": false
    }
  ],
  "meta": {
    "current_page": 1,
    "last_page": 1,
    "total": 1
  }
}
```

## Dashboard response example

### Dashboard summary

**Request**

```http
GET {{BASE_URL}}/api/staff/dashboard
Authorization: Bearer <token>
Accept: application/json
```

**Success response — `200 OK`**

```json
{
  "is_technical_head": false,
  "attendance": {
    "date": "2026-10-20",
    "checked_in": true,
    "checked_out": false,
    "can_check_in": false,
    "can_check_out": true,
    "attendance_complete": false,
    "check_in_at": "2026-10-20T08:45:00+00:00",
    "check_out_at": null
  },
  "tasks": {
    "open": 3,
    "today": 1,
    "in_progress": 1,
    "completed": 4
  },
  "site_visits": {
    "open": 2,
    "today": 1,
    "completed": 5
  },
  "projects": {
    "active": 2,
    "completed": 4
  },
  "head": null
}
```

Counts above are examples. `head` is populated only for the roles treated as heads by the dashboard; otherwise it is `null`.

## Task request and response examples

All task routes require a staff bearer token and a technician/field-team role. Task detail/update endpoints only allow the task's assigned user. `{task}` is the numeric task ID.

### Task list

**Request**

```http
GET {{BASE_URL}}/api/staff/tasks?status=assigned&per_page=20
Authorization: Bearer <token>
Accept: application/json
```

`status` is optional. Valid filter values: `unassigned`, `assigned`, `in_progress`, `completed`, `on_hold`, `customer_cancelled`, `rejected`. `per_page` defaults to 20 and is limited to 100.

**Success response — `200 OK`**

```json
{
  "data": [
    {
      "id": 12,
      "task_type": "Installation",
      "instructions": "Complete installation and record progress.",
      "scheduled_at": "2026-10-21T09:00:00+05:30",
      "status": "assigned",
      "status_label": "Assigned",
      "customer": {
        "id": 88,
        "name": "Example Customer",
        "phone": "9876500000",
        "address": "10 Example Road"
      },
      "project": {
        "id": 27,
        "project_code": "PRJ-00027",
        "status": "in_progress",
        "site_address": "10 Example Road"
      }
    }
  ],
  "meta": {
    "current_page": 1,
    "last_page": 1,
    "total": 1
  }
}
```

### Task details

**Request**

```http
GET {{BASE_URL}}/api/staff/tasks/12
Authorization: Bearer <token>
Accept: application/json
```

**Success response — `200 OK`**

```json
{
  "task": {
    "id": 12,
    "task_type": "Installation",
    "instructions": "Complete installation and record progress.",
    "scheduled_at": "2026-10-21T09:00:00+05:30",
    "status": "assigned",
    "status_label": "Assigned",
    "customer": {
      "id": 88,
      "name": "Example Customer",
      "phone": "9876500000",
      "address": "10 Example Road"
    },
    "project": {
      "id": 27,
      "project_code": "PRJ-00027",
      "status": "in_progress",
      "site_address": "10 Example Road"
    },
    "updates": []
  }
}
```

Each entry in `updates` has `id`, `from_status`, `to_status`, `remarks`, `reason`, `follow_up_date`, `follow_up_notes`, `photos` (private authenticated URLs), `updated_by`, and `created_at`.

### Shared task update response

Task start, status update, completion, and on-hold requests return this shape (`task` and `update` values vary):

```json
{
  "message": "Task update saved.",
  "task": {
    "id": 12,
    "task_type": "Installation",
    "instructions": "Complete installation and record progress.",
    "scheduled_at": "2026-10-21T09:00:00+05:30",
    "status": "in_progress",
    "status_label": "In Progress",
    "customer": {
      "id": 88,
      "name": "Example Customer",
      "phone": "9876500000",
      "address": "10 Example Road"
    },
    "project": {
      "id": 27,
      "project_code": "PRJ-00027",
      "status": "in_progress",
      "site_address": "10 Example Road"
    }
  },
  "update": {
    "id": 900,
    "from_status": "assigned",
    "to_status": "in_progress",
    "remarks": "Work started.",
    "reason": null,
    "follow_up_date": null,
    "follow_up_notes": null,
    "photos": [],
    "updated_by": {
      "id": 42,
      "name": "Example Technician"
    },
    "created_at": "2026-10-20T09:00:00+05:30"
  }
}
```

### Task start

**Request**

```http
POST {{BASE_URL}}/api/staff/tasks/12/start
Authorization: Bearer <token>
Accept: application/json
Content-Type: application/json
```

```json
{
  "remarks": "Work started."
}
```

`remarks`, `follow_up_date`, and `follow_up_notes` are optional. To attach progress photos, use multipart form-data with `photos[]` as file fields (maximum 5 images, 5 MB each). This changes `assigned` to `in_progress` and returns the shared task update response.

### Task status update

**Request**

```http
PATCH {{BASE_URL}}/api/staff/tasks/12/status
Authorization: Bearer <token>
Accept: application/json
Content-Type: application/json
```

```json
{
  "status": "on_hold",
  "reason": "Waiting for site access.",
  "remarks": "Customer asked us to return tomorrow.",
  "follow_up_date": "2026-10-21",
  "follow_up_notes": "Call the customer before travelling."
}
```

For requests containing photo files, send multipart form-data fields plus `photos[]` file fields instead of JSON. A reason is required for `on_hold`, `customer_cancelled`, and `rejected`.

### Task completion

**Request**

```http
POST {{BASE_URL}}/api/staff/tasks/12/complete
Authorization: Bearer <token>
Accept: application/json
Content-Type: application/json
```

```json
{
  "remarks": "Installation completed and checked."
}
```

`remarks` and up to five `photos[]` files are optional. The task must be `in_progress`; the result uses the shared task update response with `to_status: "completed"`.

### Delay / on-hold

**Request**

```http
POST {{BASE_URL}}/api/staff/tasks/12/on-hold
Authorization: Bearer <token>
Accept: application/json
Content-Type: application/json
```

```json
{
  "reason": "Waiting for site access.",
  "remarks": "Work paused at the customer site.",
  "follow_up_date": "2026-10-21",
  "follow_up_notes": "Call the customer before returning."
}
```

`reason` is required. Other fields and up to five `photos[]` files are optional. The task must be `assigned` or `in_progress`; the result uses the shared task update response with `to_status: "on_hold"`.

## Site visits (site survey)

A site visit is the technician's first inspection of the customer's site. The CRM head hands the customer to a Technical Head, the Technical Head picks a Technical Lead (web app), and the Technical Lead assigns a technician and a date; the technician opens the visit, starts it at the site, fills in the site survey form, and submits the report. Submitting the report **completes** the visit and marks the customer's "Site Visit Confirmation" journey step as done.

The form is **dynamic**: its questions are managed by the admin or Technical Lead in the web app (Admin → Site Survey Questions, uploaded from Excel). The app must build the form from the `questions` returned by the API and must not hard-code question IDs or labels.

### Site visit flow

```text
GET  /site-visits?status=open          → list my open visits
GET  /site-visits/{id}                 → visit details + form questions
POST /site-visits/{id}/start           → at the site (records GPS); status assigned → in_progress
POST /site-visits/{id}/report          → submit the filled form; status → completed
```

### Site visit access

- Any authenticated staff user can call the list, details, start, and report endpoints, but only for visits where they are the assigned technician (`technician.id` equals the logged-in user).
- `super_admin`, `technical_manager` (Technical Lead) and `technical_head` (Technical Head) can also open, start, or submit any visit, see all visits with `all=1`, view the assignment queue, and assign visits.
- A visit belonging to someone else returns `403` with `"This site visit is not assigned to you."`.

### Site visit statuses

| Status | Meaning | App action |
|---|---|---|
| `assigned` | Scheduled, not started | Show **Start visit** |
| `in_progress` | Technician has started at the site | Show the form and **Submit report** |
| `completed` | Report submitted | Read-only; show the report |
| `cancelled` | Cancelled by the Technical Lead / Head | Read-only |

Start and report are allowed only while the visit is `assigned` or `in_progress`; otherwise the API returns `422` with `"This site visit is already closed."`. Submitting the report directly from `assigned` is allowed; the server records the start time automatically.

### Question types

Each question has a `type`. Render and send it as follows:

| `type` | Input to show | Send in `answers[<id>]` |
|---|---|---|
| `text` | Single-line text | Text, max 1000 characters |
| `textarea` | Multi-line text | Text, max 5000 characters |
| `number` | Numeric keyboard | Number, such as `2850` |
| `yes_no` | Yes / No toggle | `yes` or `no` |
| `select` | Dropdown / radio from `options` | One value exactly as listed in `options` |
| `multi_select` | Checkboxes from `options` | `answers[<id>][]` repeated, or one comma-separated string `"G,1,2"` |
| `date` | Date picker | `YYYY-MM-DD` |
| `photo` | Camera / gallery (multiple allowed) | **Not** in `answers`; send files as `photos[<id>][]` |

When `is_required` is `true`, the server rejects the report without an answer (or, for `photo`, at least one image). `select` and `multi_select` values must exactly match the `options` strings, including case and spaces. Show `help_text`, when present, below the field.

Each question also has a `code` (for example `lift_model`, `gf_ff_height`, `customer_rep_name`) that places the answer on the printed Site Visit Report. The app can ignore it; always send answers keyed by the question `id`. The form follows the Odyssey Site Visit Report sheet: Site Details, Lift Model, Checklist, Floor Measurements and Customer Acknowledgement sections. Send the sheet's "Executive Remarks" in the `remarks` field.

### 13. Site visit list

`GET /api/staff/site-visits`

| Query parameter | Required | Notes |
|---|---|---|
| `status` | No | `open` (assigned + in progress), `assigned`, `in_progress`, `completed`, or `cancelled`. Omit for all. |
| `per_page` | No | Default 20, maximum 100 |
| `page` | No | Page number |
| `all` | No | `1` lists every visit; only for Technical Lead, Technical Head or super admin. Others always get only their own visits. |

Open visits are returned first, ordered by `scheduled_at`.

```http
GET {{BASE_URL}}/api/staff/site-visits?status=open&per_page=20
Authorization: Bearer <token>
Accept: application/json
```

**Response `200 OK`**

```json
{
  "data": [
    {
      "id": 3,
      "status": "assigned",
      "scheduled_at": "2026-10-23T06:55:00.000000Z",
      "instructions": "Call the customer 30 minutes before arriving.",
      "started_at": null,
      "completed_at": null,
      "assigned_by": "Technical Head Name",
      "technician": {
        "id": 15,
        "name": "Technician Name",
        "phone": "9876500000"
      },
      "customer": {
        "id": 6,
        "customer_code": "CUST-2026-00006",
        "name": "Customer Name",
        "phone": "9876545678",
        "email": "customer@example.com"
      },
      "site": {
        "submitted": true,
        "submitted_at": "2026-10-22T09:10:00.000000Z",
        "link_url": "https://<host>/site-details/<token>",
        "link_sent_at": "2026-10-22T08:00:00.000000Z",
        "latitude": 13.0418,
        "longitude": 80.2341,
        "map_url": "https://www.google.com/maps?q=13.0418,80.2341",
        "site_address": "Site address line",
        "landmark": "Near landmark",
        "city": "Chennai",
        "state": "Tamil Nadu",
        "pincode": "600041",
        "contact_person_name": "Site contact",
        "contact_person_phone": "9876511111",
        "alternate_phone": null,
        "preferred_visit_date": "2026-10-23",
        "notes": null
      }
    }
  ],
  "meta": {
    "current_page": 1,
    "last_page": 1,
    "total": 1
  }
}
```

`site` is `null` if the customer has not submitted site details. Use `site.map_url` (or `latitude`/`longitude`) for navigation.

### 14. Site visit details and form

`GET /api/staff/site-visits/{siteVisit}`

Returns the visit, the customer's response to the appointment, and — while the visit is open — the form questions grouped by section. For a completed visit, `questions` is an empty array and `site_visit.report` contains the submitted report.

```http
GET {{BASE_URL}}/api/staff/site-visits/3
Authorization: Bearer <token>
Accept: application/json
```

**Response `200 OK` (open visit)**

```json
{
  "site_visit": {
    "id": 3,
    "status": "assigned",
    "scheduled_at": "2026-10-23T06:55:00.000000Z",
    "instructions": null,
    "started_at": null,
    "completed_at": null,
    "assigned_by": "Technical Head Name",
    "technician": { "id": 15, "name": "Technician Name", "phone": "9876500000" },
    "customer": {
      "id": 6,
      "customer_code": "CUST-2026-00006",
      "name": "Customer Name",
      "phone": "9876545678",
      "email": "customer@example.com"
    },
    "site": { "submitted": true, "site_address": "Site address line", "map_url": "https://www.google.com/maps?q=13.0418,80.2341" },
    "report": null,
    "customer_response": {
      "appointment_status": "scheduled",
      "confirmed_at": null,
      "remarks": null
    }
  },
  "questions": [
    {
      "section": "Site Details",
      "questions": [
        {
          "id": 31,
          "code": "building_type",
          "section": "Site Details",
          "question": "Type of building",
          "type": "select",
          "options": ["Villa", "Bungalow", "Penthouse", "Individual Home", "Commercial"],
          "is_required": true,
          "help_text": null
        },
        {
          "id": 32,
          "code": "site_condition",
          "section": "Site Details",
          "question": "Site condition",
          "type": "select",
          "options": ["Existing Home", "Renovation", "Under construction"],
          "is_required": true,
          "help_text": null
        }
      ]
    },
    { "section": "Lift Model", "questions": [ … ] },
    {
      "section": "Checklist",
      "questions": [
        {
          "id": 44,
          "code": "checklist",
          "section": "Checklist",
          "question": "Checklist (tick what you have checked)",
          "type": "multi_select",
          "options": ["Pit area / floor level area", "Clear space: ground to top & top to ground", "Ground floor - door area", "…"],
          "is_required": true,
          "help_text": null
        }
      ]
    },
    {
      "section": "Floor Measurements",
      "questions": [
        {
          "id": 45,
          "code": "gf_ff_height",
          "section": "Floor Measurements",
          "question": "Ground to first floor (mm)",
          "type": "number",
          "options": [],
          "is_required": true,
          "help_text": null
        }
      ]
    },
    { "section": "Customer Acknowledgement", "questions": [ … ] }
  ]
}
```

The `site` object has the same fields as in the list response; it is shortened above. Questions without a section are grouped under `"General"`.

**Build the form from this response — do not hard-code it.** The `id`s above are only examples (they differ per server), and the sections and questions can change. Show one step per item in `questions` (5 today: Site Details, Lift Model, Checklist, Floor Measurements, Customer Acknowledgement), and in each step one input per question, using its `type` (see *Question types*). Send each answer as `answers[<id>]` with the `id` from this response. `POST …/start` does not return the questions — after starting, keep using the questions from this response (or `GET /api/staff/site-visit-questions`, which returns the same list as `sections`).

`customer_response.appointment_status` shows the customer's answer from the client app:

| Value | Meaning |
|---|---|
| `scheduled` | Assigned; customer has not responded |
| `rescheduled` | Date/technician changed by the Technical Lead / Head; customer has not responded |
| `confirmed` | Customer accepted the appointment |
| `rejected` | Customer declined |
| `reschedule_requested` | Customer asked for another date; see `remarks` |

**Completed visit — `site_visit.report`**

```json
"report": {
  "answers": [
    {
      "question_id": 31,
      "code": "building_type",
      "section": "Site Details",
      "question": "Type of building",
      "type": "select",
      "value": "Villa"
    },
    {
      "question_id": 44,
      "code": "checklist",
      "section": "Checklist",
      "question": "Checklist (tick what you have checked)",
      "type": "multi_select",
      "value": ["Pit area / floor level area", "Ground floor - door area"]
    }
  ],
  "photo_urls": ["https://<host>/storage/site-surveys/3/extra-1.jpg"],
  "customer_signature_url": "https://<host>/storage/site-surveys/3/signature.png",
  "latitude": 13.0418,
  "longitude": 80.2341,
  "remarks": "Shaft area clear. Customer prefers beige."
}
```

The report keeps each question's text and type as they were at submission time, so it still displays correctly if the admin later edits the question list. For `photo` answers, `value` is a list of image URLs; for `multi_select`, a list of strings.

### 15. Site survey questions

`GET /api/staff/site-visit-questions`

Returns the current active form without a visit, for example to pre-load or cache the form layout.

```json
{
  "sections": [
    {
      "section": "Site Details",
      "questions": [
        {
          "id": 31,
          "code": "building_type",
          "section": "Site Details",
          "question": "Type of building",
          "type": "select",
          "options": ["Villa", "Bungalow", "Penthouse", "Individual Home", "Commercial"],
          "is_required": true,
          "help_text": null
        }
      ]
    },
    { "section": "Lift Model", "questions": [ … ] },
    { "section": "Checklist", "questions": [ … ] },
    { "section": "Floor Measurements", "questions": [ … ] },
    { "section": "Customer Acknowledgement", "questions": [ … ] }
  ]
}
```

Always validate against the questions from **#14** just before submitting; the admin can change the form at any time, and the server validates against the current active questions.

### 16. Start site visit

`POST /api/staff/site-visits/{siteVisit}/start`

Call this when the technician reaches the site. It changes `assigned` to `in_progress`, records `started_at`, and stores the location. Calling it again on an `in_progress` visit changes nothing and still returns `200`.

```http
POST {{BASE_URL}}/api/staff/site-visits/3/start
Authorization: Bearer <token>
Accept: application/json
Content-Type: application/json
```

```json
{
  "latitude": 13.0418,
  "longitude": 80.2341
}
```

| Field | Required | Rule |
|---|---|---|
| `latitude` | No | Number between -90 and 90 |
| `longitude` | No | Number between -180 and 180 |

**Response `200 OK`**

```json
{
  "message": "Site visit started.",
  "site_visit": {
    "id": 3,
    "status": "in_progress",
    "started_at": "2026-10-23T07:02:11.000000Z",
    "...": "same fields as the list item"
  }
}
```

### 17. Submit site survey report

`POST /api/staff/site-visits/{siteVisit}/report` — `multipart/form-data`

Submits the whole form in one request. **There is no draft save:** keep the technician's in-progress answers on the device until this call succeeds. A report cannot be edited after it is submitted.

| Field | Required | Rule |
|---|---|---|
| `answers[<question id>]` | Per question `is_required` | See *Question types* |
| `answers[<question id>][]` | Per question | `multi_select` values, repeated |
| `photos[<question id>][]` | Per question `is_required` | Images for `photo` questions; max 10 MB each |
| `site_photos[]` | No | Extra site images; max 10 MB each |
| `customer_signature` | No | Signature image (PNG/JPG); max 4 MB |
| `latitude` | No | Number between -90 and 90 |
| `longitude` | No | Number between -180 and 180 |
| `remarks` | No | Text, max 5000 characters |

`<question id>` is the `id` of the question from #14. Answers for questions that are not in the active form are ignored.

```http
POST {{BASE_URL}}/api/staff/site-visits/3/report
Authorization: Bearer <token>
Accept: application/json
Content-Type: multipart/form-data
```

| Key | Type | Example value |
|---|---|---|
| `answers[1]` | Text | `Villa` |
| `answers[2]` | Text | `Existing` |
| `answers[3]` | Text | `4` |
| `answers[4]` | Text | `G, 1, 2, Terrace` |
| `answers[7][]` | Text | `Core Cutting` |
| `answers[9]` | Text | `yes` |
| `photos[12][]` | File | `pit.jpg` |
| `photos[12][]` | File | `pit-2.jpg` |
| `site_photos[]` | File | `front-view.jpg` |
| `customer_signature` | File | `signature.png` |
| `latitude` | Text | `13.0418` |
| `longitude` | Text | `80.2341` |
| `remarks` | Text | `Shaft area clear. Customer prefers beige.` |

**Response `201 Created`**

```json
{
  "message": "Site survey report submitted.",
  "site_visit": {
    "id": 3,
    "status": "completed",
    "started_at": "2026-10-23T07:02:11.000000Z",
    "completed_at": "2026-10-23T07:40:52.000000Z",
    "...": "same fields as #14",
    "report": {
      "answers": ["..."],
      "photo_urls": ["..."],
      "customer_signature_url": "https://<host>/storage/site-surveys/3/signature.png",
      "latitude": 13.0418,
      "longitude": 80.2341,
      "remarks": "Shaft area clear. Customer prefers beige."
    }
  }
}
```

After a successful submit, the Technical Head and the customer's CRM executive receive a web notification.

**Validation error `422`** — field keys use the question ID; `message`/`errors` text uses the question label:

```json
{
  "message": "The Type of building field is required. (and 1 more error)",
  "errors": {
    "answers.1": ["The Type of building field is required."],
    "photos.12": ["The Pit area / floor level area field is required."]
  }
}
```

Map `answers.<id>` and `photos.<id>` back to the question with that `id` to show the error under the correct field.

### 18. Site visit assignment queue (Technical Lead / Head)

`GET /api/staff/site-visits-queue` — `super_admin`, `technical_manager` and `technical_head` only; others receive `403`.

Lists customers who have submitted site details but have no site visit yet, plus the field technicians in the caller's region who can be assigned. A Technical Lead sees only the customers a Technical Head assigned to them; a Technical Head sees their region's customers plus any handed to them directly.

```json
{
  "customers": [
    {
      "id": 6,
      "customer_code": "CUST-2026-00006",
      "name": "Customer Name",
      "phone": "9876545678",
      "site": { "submitted": true, "site_address": "Site address line", "preferred_visit_date": "2026-10-23" }
    }
  ],
  "technicians": [
    { "id": 15, "name": "Technician Name", "phone": "9876500000", "role": "technician", "open_site_visits": 1, "active_tasks": 2 }
  ]
}
```

`open_site_visits` and `active_tasks` show each technician's current workload (open site visits; assigned, in-progress or on-hold tasks) so the app can show who is free before assigning.

### 19. Assign site visit (Technical Lead / Head)

`POST /api/staff/customers/{customer}/site-visit/assign` — `super_admin`, `technical_manager` and `technical_head` only.

Assigns a technician and date. If the customer already has an open visit, this **reassigns/reschedules** that visit instead of creating another one. The customer is notified of the appointment by email/SMS.

```http
POST {{BASE_URL}}/api/staff/customers/6/site-visit/assign
Authorization: Bearer <token>
Accept: application/json
Content-Type: application/json
```

```json
{
  "technician_id": 15,
  "scheduled_at": "2026-10-24 10:30:00",
  "instructions": "Call the customer 30 minutes before arriving."
}
```

| Field | Required | Rule |
|---|---|---|
| `technician_id` | Yes | A field technician (`technician`, `site_supervisor`, `installation_engineer`, `electrician` or `helper`) in the caller's region; use the `technicians` list from #18. Technical Leads and Technical Heads cannot be sent on a visit. |
| `scheduled_at` | Yes | Future date-time, `YYYY-MM-DD HH:MM:SS`, India time |
| `instructions` | No | Text, max 2000 characters |

**Response `201 Created`**

```json
{
  "message": "Site visit assigned to Technician Name.",
  "site_visit": { "id": 3, "status": "assigned", "...": "same fields as the list item" }
}
```

### Site visit errors

| Status | When |
|---|---|
| `401` | Missing, invalid, or revoked staff token |
| `403` | Visit not assigned to the user; or a user who is not a Technical Lead or Technical Head calls #18/#19 |
| `404` | Visit or customer ID does not exist; or `technician_id` is not an eligible technician in the region |
| `422` | Visit already completed/cancelled; or validation failed |

### Site visit notes for the app

- Dates in responses are ISO-8601 in UTC (`...Z`). Convert to the device's local time for display. Send `scheduled_at` in India time as shown in #19.
- Photo and signature URLs in site visit responses are direct links and open without the bearer token.
- Send `latitude`/`longitude` from the device GPS on both start and report when location permission is granted.

## Pre-Installation Visit (PIV)

Flow: the CRM head / executive requests the PIV (web) → the technical head / lead assigns a field technician and date (web or app) → the technician fills the Pre-Installation Survey on site, the customer signs, and submitting completes the journey's PIV step.

| Method | Path | Who |
|---|---|---|
| `GET` | `/api/staff/piv-visits?status=open` | Technician: own visits. Head / lead: their customers' visits. `status`: `open`, `requested`, `assigned`, `in_progress`, `completed`. |
| `GET` | `/api/staff/piv-visits/{piv}` | The visit, `can_fill` / `can_assign`, the `form` while open, and the `report` once submitted |
| `POST` | `/api/staff/piv-visits/{piv}/start` | Assigned technician: `{latitude?, longitude?}` |
| `POST` | `/api/staff/piv-visits/{piv}/report` | Assigned technician, multipart (below) |
| `POST` | `/api/staff/piv-visits/{piv}/assign` | Head / lead: `{technician_id, scheduled_at, instructions?}` |

`form` gives everything to build the screen: `suggestions` (model, no. of stops), `installation_types`, `measurements` (each with `code`, `label`, `previous` — the Site Visit Report value — and `required`), `electrical` + `electrical_options`, `shaft` + `shaft_options`, `photos` (each `code`, `label`, `max`), and `technicians` for "Technician 2".

**Report fields (multipart):**

| Field | Rule |
|---|---|
| `construction_stage`, `model` | Required text |
| `installation_type` | `Indoor`, `Outdoor` or `Semi-Outdoor` |
| `no_of_stops` | Required number |
| `measurements[floor_0_1]`, `measurements[headroom]`, `measurements[total_travel]` | Required, mm |
| `measurements[floor_1_2]`, `measurements[floor_2_3]`, `measurements[total_shaft]` | Optional, mm |
| `electrical[stabilizer\|power_supply\|socket_mcb\|earthing\|power_backup]` | `available`, `not_available` or `na` |
| `shaft[core_cutting_plumb\|shaft_plumb\|shaft_painted\|base_spirit_level\|scaffold\|pit_area]` | `yes`, `no` or `na` |
| `photos[outside\|ground_floor\|landings\|pit\|shaft_top_view\|shaft_down_view\|power_pack][]` | 1–5 images each, required |
| `video` | Optional walkthrough video (mp4/mov/avi/webm/3gp, max 50 MB) |
| `remarks`, `second_technician_id` | Optional |
| `customer_ack_name`, `customer_ack_phone` | Required |
| `customer_ack_remarks` | Optional |
| `customer_signature` | Required image |
| `latitude`, `longitude` | Optional |

The response's `report` has `measurements` (previous / present / difference), `issues` (items marked Not Available / No — they may hold up the dispatch), photo URLs, the customer acknowledgement and `pdf_url`.

The customer sees the PIV status and downloads the signed report in the customer app — see [customer_mobile_api.md](customer_mobile_api.md) (#20–#21).

## Change password (A4)

`POST /api/staff/change-password` — JSON. The current device stays logged in; other devices are signed out.

```json
{ "current_password": "old-pass", "password": "new-pass-123", "password_confirmation": "new-pass-123" }
```

`200`: `{ "message": "Password changed. Other devices have been signed out." }` · `422` with `errors.current_password` when the current password is wrong; the new password needs 8+ characters and must differ from the current one.

## Dashboard (5) — additional counts

`GET /api/staff/dashboard` now also returns (alongside `attendance`, `tasks`, `site_visits`, `projects`):

```json
{
  "is_technical_head": false,
  "piv": { "open": 1, "today": 0, "completed": 3 },
  "tickets": { "raised_pending": 1, "awaiting_my_approval": 0 },
  "expenses": { "mine_open": 2, "awaiting_my_decision": 0 },
  "notifications": { "unread": 4 },
  "head": null
}
```

`is_technical_head` is `true` for Technical Head and Technical Lead; then `head` is filled: `{ "unassigned_site_visits": 2, "projects_without_team": 0, "piv_to_assign": 1 }`. `tickets.awaiting_my_approval` and `expenses.awaiting_my_decision` are non-zero only for the Technical Head.

## Notifications (N1–N4)

The same notifications as the web bell (site visit / PIV assigned, ticket and expense updates, site readiness, delivery, feedback…).

`GET /api/staff/notifications?unread_only=1&per_page=20`

```json
{
  "data": [
    {
      "id": "9c1f0a52-7b1d-4f7e-9a40-2c1f5e0b8d11",
      "title": "New PIV assigned",
      "message": "Customer Name (CUST-2026-00010) on 27 Oct 2026, 10:30 AM.",
      "icon": "bi-clipboard-check",
      "web_url": "https://odyssey-crm.dextragroups.com/piv-visits/4",
      "read": false,
      "read_at": null,
      "created_at": "2026-10-26T09:12:44.000000Z"
    }
  ],
  "meta": { "current_page": 1, "last_page": 1, "total": 1 },
  "unread_count": 1
}
```

- `GET /api/staff/notifications/unread-count` → `{ "unread_count": 3 }` (use for the bell badge).
- `POST /api/staff/notifications/{id}/read` → `{ "notification": {…, "read": true}, "unread_count": 2 }`.
- `POST /api/staff/notifications/read-all` → `{ "message": "All notifications marked as read.", "unread_count": 0 }`.
- Another user's notification ID returns `404`.

## Technical tickets (K1–K6)

Field staff raise a **Callback / Repair / Upgradation / Service** ticket on a project they work on. The **Technical Head** (in the app) or the customer's CRM executive (web) approves or rejects it; approved tickets go to the planner, and when assigned they appear in the technician's **Tasks** (#6).

**K2 — options for the "Raise ticket" form:** `GET /api/staff/tickets/options`

```json
{
  "types": ["Callback", "Repair", "Upgradation", "Service"],
  "projects": [
    { "id": 9, "project_code": "PRJ-2026-00009", "customer": { "id": 10, "customer_code": "CUST-2026-00010", "name": "Customer Name" }, "status": "confirmed" }
  ]
}
```

`projects` lists the projects whose team includes the user (projects are created automatically when the order moves to production). An empty list means the user is not on any project team yet.

**K3 — raise:** `POST /api/staff/tickets`

```json
{ "task_type": "Repair", "project_id": 9, "ticket_title": "Door sensor fault", "instructions": "Door not closing on the 2nd floor" }
```

`201`:

```json
{
  "message": "Repair ticket submitted for approval.",
  "ticket": {
    "id": 41,
    "type": "Repair",
    "title": "Door sensor fault",
    "details": "Door not closing on the 2nd floor",
    "approval_status": "pending",
    "approval_comment": null,
    "approved_by": null,
    "approved_at": null,
    "status": "unassigned",
    "status_label": "Unassigned",
    "scheduled_at": null,
    "assigned_to": null,
    "requested_by": { "id": 15, "name": "Technician Name" },
    "customer": { "id": 10, "customer_code": "CUST-2026-00010", "name": "Customer Name", "phone": "9034656344" },
    "project": { "id": 9, "project_code": "PRJ-2026-00009" },
    "created_at": "2026-10-26T09:20:00.000000Z",
    "can_review": false
  }
}
```

**K1 — list:** `GET /api/staff/tickets?approval_status=pending&type=Repair&awaiting_my_approval=1&per_page=20` → `{ "data": [ticket…], "meta": {…}, "can_create": true }`. Technicians see tickets they raised or are assigned; the Technical Head sees the region's tickets — `awaiting_my_approval=1` shows only the ones they can approve (`can_review: true`).

**K4 — details:** `GET /api/staff/tickets/{ticket}` → the ticket plus `history` (status changes once it is being worked: `from_status`, `to_status`, `remarks`, `reason`, `by`, `at`). Tickets the user can't see return `404`.

**K5 / K6 — Technical Head:** `POST /api/staff/tickets/{ticket}/approve` (no body) · `POST /api/staff/tickets/{ticket}/reject` with `{ "approval_comment": "Duplicate of #40" }` (required). Both return `{ "message", "ticket" }`; `422` if the ticket is no longer pending, `403` if the user can't review it.

## Expense claims — new APIs (E1, E2, E5–E7)

Flow: technician submits (#12) → Technical Lead adds a review comment (E4, optional) → **Technical Head approves or rejects (E5)** → **marks processing (E6)** → **marks reimbursed (E7)**.

**E1 — submit form data:** `GET /api/staff/attendance/expenses/tasks`

```json
{
  "categories": ["travel", "food", "accommodation"],
  "tasks": [
    { "type": "project", "id": 9, "label": "PRJ-2026-00009 — Customer Name" },
    { "type": "site_visit", "id": 6, "label": "Site visit — Customer Name (#6)" }
  ]
}
```

Use `type` / `id` as `task_type` / `task_id` when submitting (#12).

**E2 — summary:** `GET /api/staff/attendance/expenses/summary` (own claims; Lead/Head: their team)

```json
{ "summary": {
  "submitted": { "count": 1, "total": 250 }, "under_review": { "count": 0, "total": 0 },
  "approved": { "count": 0, "total": 0 }, "rejected": { "count": 0, "total": 0 },
  "processing": { "count": 0, "total": 0 }, "reimbursed": { "count": 2, "total": 1800 }
} }
```

**E5 — approve / reject (Technical Head):** `POST /api/staff/attendance/expenses/{expense}/decision`

```json
{ "action": "approve", "comment": "OK" }
```
```json
{ "action": "reject", "rejection_reason": "No bill attached" }
```

**E6 — processing:** `POST …/{expense}/processing` with optional `{ "comment": "…" }` (claim must be `approved`).

**E7 — reimbursed:** `POST …/{expense}/reimbursement` with optional `{ "reimbursement_reference": "UPI-123", "comment": "…" }` (claim must be `processing`).

All three return `{ "message", "expense" }` (same shape as the claim details), with `status` updated and the action added to `reviews`. `403` for anyone other than the Technical Head of the claimant; `422` when the claim is not in the right status (e.g. reject without a reason, process before approval).

## Customer details (C2)

`GET /api/staff/customers/{customer}` — everything the app shows on a customer screen. Only customers the user works on; others return `403`.

```json
{
  "customer": {
    "id": 10,
    "customer_code": "CUST-2026-00010",
    "name": "Customer Name",
    "phone": "9034656344",
    "email": "customer@example.com",
    "address": "…",
    "region": "West Region",
    "current_step": { "key": "bom", "title": "BOM" },
    "in_technical_tasks": false,
    "site": { "site_address": "…", "map_url": "https://www.google.com/maps?q=13.04,80.23", "contact_person_name": "…", "contact_person_phone": "…" },
    "site_readiness": { "available": true, "status": "submitted", "photos": ["https://odyssey-crm.dextragroups.com/storage/site-readiness/customer-10/a.jpg"], "remarks": "…", "submitted_at": "…", "locked_reason": null },
    "crm_executive": { "id": 186, "name": "CRM Executive", "phone": "…" },
    "technical_head": { "id": 191, "name": "Technical Head", "phone": "…" },
    "technical_lead": { "id": 190, "name": "Technical Lead", "phone": "…" },
    "projects": [ { "id": 9, "project_code": "PRJ-2026-00009", "lift_type": "ORBIT STD 1100", "no_of_stops": 3, "status": "confirmed" } ],
    "journey": [
      { "no": 1, "key": "welcome_call", "title": "Welcome Call", "state": "completed", "completed_at": "…", "completed_by": "CRM Executive", "remarks": "…" },
      { "no": 10, "key": "bom", "title": "BOM", "state": "current", "completed_at": null, "completed_by": null, "remarks": null }
    ],
    "drawing": { "file_url": "https://odyssey-crm.dextragroups.com/storage/customer-drawings/10/x.pdf", "uploaded_at": "…", "customer_approved": true },
    "site_visit_report": { "id": 6, "completed_at": "…", "technician": "Technician Name", "pdf_url": "https://odyssey-crm.dextragroups.com/api/staff/reports/site-visits/6/pdf" },
    "piv": { "id": 4, "status": "completed", "scheduled_at": "…", "issues": [], "pdf_url": "https://odyssey-crm.dextragroups.com/api/staff/reports/piv/4/pdf" },
    "can_add_daily_update": false
  }
}
```

`journey[].state` is `completed`, `current` (the step in progress) or `pending`. `drawing`, `site_visit_report` and `piv` are `null` until they exist.

## Report PDFs (R1, R2)

`GET /api/staff/reports/site-visits/{siteVisit}/pdf` and `GET /api/staff/reports/piv/{piv}/pdf` return the PDF (`Content-Type: application/pdf`, downloaded as `site-visit-report-CUST-….pdf` / `piv-report-CUST-….pdf`). Send the bearer token — use the `pdf_url` values from C2 / P2. `404` until the report is submitted; `403` for customers the user doesn't work on.

## Error response examples

Validation errors typically return `422 Unprocessable Entity`:

```json
{
  "message": "The photo field is required.",
  "errors": {
    "photo": [
      "The photo field is required."
    ]
  }
}
```

An unauthenticated protected request returns `401 Unauthorized`. A valid token without permission, or a request to another user's task, returns `403 Forbidden`. A task ID that does not exist returns `404 Not Found`. For login credential failure, check `errors.login` in the `422` response.

## Mobile integration notes

- Use the camera API in the mobile app to capture a photo; Postman can only select a saved image file.
- Send attendance photos as multipart form-data field `photo`. Send task progress images as multipart form-data field(s) `photos[]`.
- Let the HTTP library generate the multipart `Content-Type` boundary; do not manually set `Content-Type: multipart/form-data`.
- Never send database photo columns (`check_in_photo_path`, `check_out_photo_path`) in a request. The server generates and stores these paths.
- Use the `photo_url` values returned by the API to request private images with the same bearer token.
- Store the bearer token in platform secure storage and clear it after logout.
- For attendance UI state, refresh `/api/staff/attendance/status` on app launch/resume and at date rollover. Use that response's `can_check_in`, `can_check_out`, and `attendance_complete` flags; a completed checkout is for that response's `date`, not a permanent state.
