# 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 |
| 4a | Team attendance — daily / monthly | `GET` | `/api/staff/attendance/reports/daily` · `/api/staff/attendance/reports/monthly` | T (own), L, H |
| 4b | Attendance photo | `GET` | `/api/staff/attendance/{attendance}/photos/{kind}` | T (own), 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 — **retired**: `GET` lists older entries; `POST` returns `403`. Post installation progress on the Installation task (#9 with `activities[]`) | `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 |
| C5 | Mark journey step done (remarks, photos) | `POST` | `/api/staff/customers/{customer}/journey/{stepKey}` | T, L, H (when `can_complete`) |
| C6 | Reopen journey step | `DELETE` | `/api/staff/customers/{customer}/journey/{stepKey}` | T, L, H (when `can_reopen`) |
| **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 |
| **Handover Report** |||||
| H1 | Handover form fields (26-point checklist) | `GET` | `/api/staff/handover-meta` | T, L, H |
| H2 | Handover work orders (mine / my customers) | `GET` | `/api/staff/handover-reports` | T, L, H |
| H3 | Handover details | `GET` | `/api/staff/handover-reports/{handoverReport}` | T, L, H |
| H4 | Request a handover for a project | `POST` | `/api/staff/projects/{project}/handover-report` | L, H |
| H5 | Assign technicians | `POST` | `/api/staff/handover-reports/{handoverReport}/assign` | L, H |
| H6 | Start / submit the Handover Report | `POST` | `/api/staff/handover-reports/{handoverReport}/start` · `…/submit` | T |
| H7 | Handover Report PDF | `GET` | `/api/staff/reports/handover/{handoverReport}/pdf` | Whoever can see the customer |
| **Incentive** |||||
| I1 | My incentive — month summary (targets, earned, status) | `GET` | `/api/staff/incentives/summary?month=YYYY-MM-DD` | T, L, H |
| I2 | My incentive — history | `GET` | `/api/staff/incentives/history` | T, L, H |
| I3 | My incentive — one month in detail (approvals, payment) | `GET` | `/api/staff/incentives/{incentive}` | T, L, H (own only) |

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 every field role (`technician`, `site_supervisor`, `installation_engineer`, `electrician`, `helper`). 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

Check-in and check-out are self-service only and need a photo taken with the device camera (sent as `multipart/form-data`). A task is optional and never blocks check-in or check-out. Times use the application timezone (`Asia/Kolkata`). Photos are stored privately and can only be fetched through the authenticated photo endpoint (4b).

### 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.

**Task update needed before check-out.** If the staff member is on a task that is `assigned` or `in_progress` and scheduled for today or earlier, someone on that task must have posted an update **today** (start, status change, or a progress update keeping the same status — see **9. Task Status Update**). One update from **anyone** on the task covers the whole team: when three technicians share a task and one posts the update, all three can check out. Otherwise check-out returns `422`:

```json
{
  "message": "Post today's update on your task before checking out: Installation - Example Customer. One update from anyone on the task is enough.",
  "errors": { "tasks": ["Post today's update on your task before checking out: …"] },
  "task_ids": [12]
}
```

Tasks scheduled for a later date, `on_hold` and closed tasks never block check-out. Use `tasks_awaiting_update` from **3. Attendance Status** to show which tasks need an update before the Check-Out button.

### 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).

### 4a. Team attendance reports

- `GET /api/staff/attendance/reports/daily?date=YYYY-MM-DD`
- `GET /api/staff/attendance/reports/monthly?month=YYYY-MM`

Who appears: the Technical Head sees their own records and the technical team's records in their region (company-wide if they have no region); the Technical Lead (`technical_manager`) sees their own records and the staff who report to them (`reports_to_id`); field staff see only their own records.

### 4b. Attendance photos

Photo URLs in attendance responses point to `GET /api/staff/attendance/{attendance}/photos/{kind}`, where `kind` is `check-in` or `check-out`. Send the bearer token. The same own / team / region rules as the reports apply.

### Automatic check-out

An attendance left open is closed automatically at `23:59:59` on its own date by the `attendance:auto-checkout` command, which the scheduler runs daily at midnight (the server's cron must be running). It sets `auto_checked_out` to `true` and has no check-out photo. The next day the app should rely on **3. Attendance Status**, not yesterday's state.

## 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.

**Installation plan.** An `Installation` task follows the client's 19-step installation schedule (Basement Columns & Ring … Testing & Commissioning … Handover). When it is scheduled, the plan for the building height (`G+1` 12 days, `G+2` 14 days, `G+3` 16 days — from the site visit report's "No. of floors", else the project's stops) is created and each activity gets a `planned_date` from the start day. Every task has `installation` (`null` for other work): `{ "floors": "G+2", "done": 7, "in_progress": 1, "total": 19, "percent": 37, "delayed": false, "finish_by": "2026-11-12" }`; task details (#7) also return `installation_activities`: `[{ "id", "sort", "name", "planned_day", "planned_date", "status", "done", "started_at", "started_by", "days_worked", "completed_at", "completed_by", "overdue" }]` — `status` is `pending`, `in_progress` or `completed`; `days_worked` counts the start day as day 1. A step can take several days. With any update (#8–#11) send the ids of steps **started** today but not finished as `activities_in_progress[]` and of steps **finished** today as `activities[]` (a step can be finished without being started first). A step in progress stays so on later days until it is sent in `activities[]` — keep posting the daily update meanwhile. On the update screen show, per step not yet done, a choice: *Not today / Started* (pending steps) or *Still in progress* (started steps) and *Completed*; show in-progress steps with "In progress · day N". **Complete** (#10) is refused with `422` until every activity is ticked (they can be ticked in the same request). Completing the installation completes the customer's "Technical Tasks" journey step. The customer handover itself is then done with a **Handover Report** work order (H1–H7).

A task can have **several technicians** (the Technical Lead / Head ticks them on the web Work Queue). Every one of them sees the task, and any of them can start it, post updates, put it on hold or complete it — the status is shared. Each task has `technicians` (everyone on it) and `updated_today` (`true` once anyone on the task has posted an update today — needed before check-out, see **2. Check-Out**).

### 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 reports and photos, see **4a** and **4b** under Attendance.

## 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,
  "tasks_awaiting_update": [],
  "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
}
```

`tasks_awaiting_update` lists the user's tasks that still need today's update before check-out (`id`, `task_type`, `title`, `customer`, `scheduled_at`, `status`); while it is not empty, `can_check_out` is `false`. Open the task and post an update (any one technician on the task is enough), then refresh the status.

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"
      },
      "technicians": [
        { "id": 15, "name": "Arun", "phone": "9876500001" },
        { "id": 16, "name": "Bala", "phone": "9876500002" }
      ],
      "updated_today": false
    }
  ],
  "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 they are sent on. A visit can have several technicians (`technicians`; `technician` is the main one) — any of them can start it and submit the report.
- `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",
      "status_label": "Scheduled",
      "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 — use it to **preview** the report in the app (answers by section, `photo_urls`, `technician_signature_url`, `customer_signature_url`, `remarks`) — and `report_pdf_url` links to the PDF (R1); it is `null` while the visit is open.

```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",
    "status_label": "Scheduled",
    "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" },
    "technicians": [{ "id": 15, "name": "Technician Name", "phone": "9876500000" }, { "id": 18, "name": "Second Technician", "phone": "9876500001" }],
    "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",
      "appointment_status_label": "Appointment 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. Each question also has `default`: pre-fill the field with it when it is not `null` — the customer's name and phone (`customer_rep_name`, `customer_rep_phone`) come from the quotation; the technician can change them. `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, and `appointment_status_label` is the human-readable version:

| Value | Label | Meaning |
|---|---|---|
| `scheduled` | `Appointment scheduled` | Assigned; customer has not responded |
| `rescheduled` | `Rescheduled` | Date/technician changed by the Technical Lead / Head; customer has not responded |
| `confirmed` | `Customer confirmed` | Customer accepted the appointment |
| `rejected` | `Customer declined` | Customer declined |
| `reschedule_requested` | `Customer requested reschedule` | 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",
  "technician_signature_url": "https://<host>/storage/site-surveys/3/technician-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 |
| `technician_signature` | No | Technician's signature image (PNG/JPG); max 4 MB — shown on the report's "Executive Details" |
| `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",
      "technician_signature_url": "https://<host>/storage/site-surveys/3/technician-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 one or more technicians and the 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_ids": [15, 18],
  "scheduled_at": "2026-10-24 10:30:00",
  "instructions": "Call the customer 30 minutes before arriving."
}
```

| Field | Required | Rule |
|---|---|---|
| `technician_ids` | Yes | One or more field technicians (`technician`, `site_supervisor`, `installation_engineer`, `electrician` or `helper`) in the caller's region, up to 10; use the `technicians` list from #18 (show checkboxes). The **first** is the main technician. Technical Leads and Technical Heads cannot be sent on a visit. A single `technician_id` is still accepted. On a reassign, the list replaces the previous team. |
| `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 Bala, Arun.",
  "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?}` |

### P1. PIV list

`GET /api/staff/piv-visits?status=open&per_page=20`

Open visits (status `requested`, `assigned`, `in_progress`) are returned first, ordered by `id` descending. Technicians see only their own visits; Technical Head / Lead see all visits for their customers.

```json
{
  "data": [
    {
      "id": 4,
      "status": "requested",
      "requested_for": null,
      "request_instructions": "Check shaft clearances and earthing.",
      "scheduled_at": null,
      "instructions": null,
      "started_at": null,
      "completed_at": null,
      "technician": null,
      "technicians": [],
      "customer": {
        "id": 10,
        "customer_code": "CUST-2026-00010",
        "name": "Customer Name",
        "phone": "9034656344"
      },
      "site": { "site_address": "Site address line", "map_url": "https://www.google.com/maps?q=13.04,80.23" }
    }
  ],
  "meta": { "current_page": 1, "last_page": 1, "total": 1 }
}
```

### PIV statuses

| Status | Meaning | Who can act | App: what to show |
|---|---|---|---|
| `requested` | CRM executive created the PIV; no technician assigned yet | Head / lead only | Show as **"Pending Assignment"** — a read-only preview card; no Start / Fill button. `can_fill: false`, `can_assign` is `true` for heads/leads. |
| `assigned` | Technician and date set by the head / lead | Assigned technician | Show **"Start PIV"** button |
| `in_progress` | Technician started at the site | Assigned technician | Show the PIV form and **"Submit Report"** button |
| `completed` | Report submitted | — | Read-only; show the report and PDF link |
| `cancelled` | Cancelled | — | Read-only |

> **Why the technician sees a `requested` PIV (but can't fill it):**
> When a CRM executive creates a PIV from the web, it starts at `requested` status with **no technician assigned**. The PIV appears in the technician app list (so the team is aware it's coming), but `can_fill` is `false` and there is **no form**. The Technical Head or Technical Lead must assign the PIV (P5) before any technician can start or fill it. **There is no `draft` status** — `requested` is the first real status, not a draft.

### P2. PIV details + form / report

`GET /api/staff/piv-visits/{piv}`

Returns the full PIV record with `can_fill`, `can_assign`, the `form` (while open and assigned), and `report` (once submitted). The `form` is `null` when status is `requested` (not yet assigned) — show a preview/info card instead.

```http
GET {{BASE_URL}}/api/staff/piv-visits/4
Authorization: Bearer <token>
Accept: application/json
```

**Response — PIV in `requested` state (preview only, no form)**

```json
{
  "piv": {
    "id": 4,
    "status": "requested",
    "requested_for": null,
    "request_instructions": "Check shaft clearances and earthing.",
    "scheduled_at": null,
    "instructions": null,
    "started_at": null,
    "completed_at": null,
    "technician": null,
    "technicians": [],
    "can_fill": false,
    "can_assign": true,
    "report": null,
    "customer": {
      "id": 10,
      "customer_code": "CUST-2026-00010",
      "name": "Customer Name",
      "phone": "9034656344"
    },
    "site": { "site_address": "Site address line", "map_url": "https://www.google.com/maps?q=13.04,80.23" }
  },
  "form": null
}
```

**Response — PIV assigned (form visible)**

```json
{
  "piv": {
    "id": 4,
    "status": "assigned",
    "scheduled_at": "2026-10-28T05:00:00.000000Z",
    "instructions": "Call customer 30 min before arriving.",
    "technician": { "id": 15, "name": "Technician Name", "phone": "9000000001" },
    "technicians": [{ "id": 15, "name": "Technician Name", "phone": "9000000001" }],
    "can_fill": true,
    "can_assign": false,
    "report": null,
    "customer": { "id": 10, "customer_code": "CUST-2026-00010", "name": "Customer Name", "phone": "9034656344" },
    "site": { "site_address": "Site address line", "map_url": "https://…" }
  },
  "form": {
    "suggestions": { "model": "ORBIT STD 1100", "no_of_stops": 3 },
    "installation_types": ["Indoor", "Outdoor", "Semi-Outdoor"],
    "measurements": [
      { "code": "floor_0_1", "label": "Ground to 1st floor (mm)", "previous": 2850, "required": true },
      { "code": "headroom", "label": "Headroom (mm)", "previous": null, "required": true },
      { "code": "total_travel", "label": "Total travel (mm)", "previous": null, "required": true },
      { "code": "floor_1_2", "label": "1st to 2nd floor (mm)", "previous": null, "required": false }
    ],
    "electrical": [
      { "code": "stabilizer", "label": "Stabilizer" },
      { "code": "power_supply", "label": "Power Supply" },
      { "code": "socket_mcb", "label": "Socket with MCB" },
      { "code": "earthing", "label": "Earthing (Copper-2Nos)" },
      { "code": "power_backup", "label": "Power Backup" }
    ],
    "electrical_options": ["available", "not_available", "na"],
    "shaft": [
      { "code": "core_cutting_plumb", "label": "Core cutting plumb" },
      { "code": "shaft_plumb", "label": "Shaft plumb" },
      { "code": "shaft_painted", "label": "Shaft painted" },
      { "code": "base_spirit_level", "label": "Base spirit level" },
      { "code": "scaffold", "label": "Scaffold ready" },
      { "code": "pit_area", "label": "Pit area clear" }
    ],
    "shaft_options": ["yes", "no", "na"],
    "photos": [
      { "code": "outside", "label": "Outside view", "max": 5 },
      { "code": "ground_floor", "label": "Ground floor", "max": 5 },
      { "code": "landings", "label": "Landings", "max": 5 },
      { "code": "pit", "label": "Pit area", "max": 5 },
      { "code": "shaft_top_view", "label": "Shaft top view", "max": 5 },
      { "code": "shaft_down_view", "label": "Shaft down view", "max": 5 },
      { "code": "power_pack", "label": "Power pack area", "max": 5 }
    ],
    "technicians": [{ "id": 18, "name": "Second Technician" }]
  }
}
```

**Rendering logic for the app:**

| `piv.status` | `form` | What to show |
|---|---|---|
| `requested` | `null` | Preview card: customer name, request instructions, `"Waiting for assignment by Technical Head"` |
| `assigned` | present | Preview card + **"Start PIV"** button |
| `in_progress` | present | Full PIV form + **"Submit Report"** button |
| `completed` | `null` | `piv.report` — completed report view + PDF link |
| `cancelled` | `null` | Cancelled badge |

`form` gives everything to build the screen: `suggestions` (model, no. of stops from the site visit report), `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".

### P3. Start PIV

`POST /api/staff/piv-visits/{piv}/start` — call when the technician reaches the site. Changes `assigned` → `in_progress`. Returns `{ "message", "piv": { ...summary fields... } }`.

```json
{ "latitude": 13.0418, "longitude": 80.2341 }
```

`403` if `can_fill` is `false` (not assigned to you, or PIV is still `requested`).

### P4. Submit PIV report

`POST /api/staff/piv-visits/{piv}/report` — `multipart/form-data`

**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 |
| `technician_signature` | Optional image — the technician's signature, shown on the report's "Executive Details"; returned as `technician_signature_url` |
| `latitude`, `longitude` | Optional |

**Response `201 Created`**

```json
{
  "message": "PIV report submitted.",
  "piv": {
    "id": 4,
    "status": "completed",
    "scheduled_at": "2026-10-28T05:00:00.000000Z",
    "completed_at": "2026-10-28T06:10:00.000000Z",
    "technician": { "id": 15, "name": "Technician Name", "phone": "9000000001" },
    "customer": { "id": 10, "customer_code": "CUST-2026-00010", "name": "Customer Name", "phone": "9034656344" },
    "report": {
      "answers": {
        "construction_stage": "Completed",
        "model": "ORBIT STD 1100",
        "installation_type": "Indoor",
        "no_of_stops": 3,
        "measurements": { "floor_0_1": 2850, "headroom": 2000, "total_travel": 8550 },
        "electrical": { "stabilizer": "available", "power_supply": "available", "earthing": "not_available" },
        "shaft": { "core_cutting_plumb": "yes", "shaft_plumb": "yes", "pit_area": "yes" }
      },
      "measurements": [
        { "code": "floor_0_1", "label": "Ground to 1st floor (mm)", "previous": 2850, "present": 2850, "difference": 0 }
      ],
      "issues": ["Earthing (Copper-2Nos): Not Available"],
      "photos": {
        "outside": ["https://<host>/storage/piv/4/outside-1.jpg"],
        "pit": ["https://<host>/storage/piv/4/pit-1.jpg", "https://<host>/storage/piv/4/pit-2.jpg"]
      },
      "video_url": null,
      "remarks": "Earthing pending from customer side.",
      "second_technician": null,
      "customer_acknowledgement": {
        "name": "Customer Name",
        "phone": "9876543210",
        "remarks": null,
        "signature_url": "https://<host>/storage/piv/4/signature.png"
      },
      "pdf_url": "https://odyssey-crm.dextragroups.com/api/staff/reports/piv/4/pdf"
    }
  }
}
```

`report.issues` lists items marked `not_available` or `no` — these may hold up the dispatch. When `issues` is empty the site is ready.

### P5. Assign PIV (Technical Head / Lead)

`POST /api/staff/piv-visits/{piv}/assign`

Assigns one or more technicians and sets the date. Works on `requested`, `assigned` and `in_progress` PIVs.

```json
{
  "technician_ids": [15, 18],
  "scheduled_at": "2026-10-28 10:30:00",
  "instructions": "Bring the measurement kit."
}
```

| Field | Required | Rule |
|---|---|---|
| `technician_ids` | Yes | One or more field technicians in the caller's region, up to 5. The **first** is the lead technician. A single `technician_id` (legacy) is also accepted. |
| `scheduled_at` | Yes | Future date-time `YYYY-MM-DD HH:MM:SS`, India time |
| `instructions` | No | Text, max 2000 characters |

**Response `200 OK`**

```json
{
  "message": "PIV assigned to Technician Name, Second Technician.",
  "piv": {
    "id": 4,
    "status": "assigned",
    "scheduled_at": "2026-10-28T05:00:00.000000Z",
    "technician": { "id": 15, "name": "Technician Name", "phone": "9000000001" },
    "technicians": [
      { "id": 15, "name": "Technician Name", "phone": "9000000001" },
      { "id": 18, "name": "Second Technician", "phone": "9000000002" }
    ],
    "customer": { "id": 10, "customer_code": "CUST-2026-00010", "name": "Customer Name", "phone": "9034656344" },
    "site": { "site_address": "Site address line", "map_url": "https://…" }
  }
}
```

After assigning, the customer is notified by SMS and each technician receives an in-app notification.

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": "…",
        "image_urls": [], "requires_upload": false, "upload_type": "photo", "can_complete": false, "can_reopen": false },
      { "no": 16, "key": "technical_tasks", "title": "Technical Tasks", "state": "current", "completed_at": null, "completed_by": null, "remarks": null,
        "image_urls": [], "requires_upload": false, "upload_type": "photo", "can_complete": false, "can_reopen": false }
    ],
    "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
  }
}
```

**Stages (show these as the tracker).** The client's journey has 11 stages: Quote → Order Confirmation → Welcome Call → Site Survey → Drawing Upload & Confirmation → Moved to Production → PIV → Dispatch & Delivery → Installation → Handover → S&M. `stages` lists them in order: `{ "no": 4, "key": "site_survey", "label": "Site Survey", "state": "current", "completed_at": null, "done": 1, "total": 4, "steps": [{ "key": "site_details", "label": "Site Details", "state": "completed", "completed_at": "…" }, …] }`. `state` is `completed`, `current` or `pending`; `done`/`total` count the working steps inside the stage (show "1/4" on the current stage, and its `steps` as sub-steps). Quote and Order Confirmation are always `completed`. Stage keys: `quote`, `order_confirmation`, `welcome_call`, `site_survey`, `drawing`, `production` (payment, production, BOM, inventory), `piv` (site readiness, PIV), `dispatch_delivery`, `installation`, `handover` (handover report, feedback), `service` (project closed → service & maintenance). `customer.stages` has them; `journey` keeps every working step for details and actions.

`journey[].state` is `completed`, `current` (the step in progress) or `pending`. `drawing`, `site_visit_report` and `piv` are `null` until they exist.

Show a **Mark as done** button on a step when `can_complete` is `true` (C5), and **Reopen** when `can_reopen` is `true` (C6). `image_urls` are the photos / videos saved with a completed step.

## Journey step — mark done / reopen (C5, C6)

The same step update the technical team does on the web customer page, for technical steps the admin adds. **Technical Tasks** is never marked here — it completes when the Installation task is completed. Only steps with `can_complete` / `can_reopen` in C2 are allowed; steps that finish through their own flow (site visit report, drawing, customer's site readiness / feedback, PIV report) or are done by the CRM / technical head (payment, BOM, inventory, production, dispatch, closing) are refused.

**C5** `POST /api/staff/customers/{customer}/journey/{stepKey}` — `multipart/form-data`:

| Field | Rule |
|---|---|
| `remarks` | Optional, max 5000 characters |
| `images[]` | Photos (jpg / png / webp, max 5 MB) — or videos (mp4 / mov / avi / webm, max 50 MB) when `upload_type` is `video` / `either`. **Required** when `requires_upload` is `true` |

```json
{
  "message": "Shaft Check marked as done.",
  "step": {
    "key": "shaft_check",
    "title": "Shaft Check",
    "completed_at": "2026-10-26T09:30:00.000000Z",
    "remarks": "Installation finished, lift running.",
    "image_urls": ["https://odyssey-crm.dextragroups.com/storage/journey-steps/abc.jpg"]
  }
}
```

**C6** `DELETE /api/staff/customers/{customer}/journey/{stepKey}` → `{ "message": "Technical Tasks reopened." }` (removes the step's photos too).

Errors: `403` "You are not allowed to update this step." (not your step / customer); `422` with the reason when the step finishes through its own flow, e.g. "This step completes when the assigned technician submits the site survey report.", or "This step is updated from the web."; `422` "… is not done yet." when reopening an open step.

## 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.

## Handover Report (H1–H7)

After the installation, the technical lead / head (or the CRM) requests a handover for the project (H4), assigns **one or more technicians** (H5), and the technicians fill the Odyssey Handover Report with the customer (H6). Submitting it completes the customer's **Site Handover** journey step.

**H5 Assign** `POST /api/staff/handover-reports/{id}/assign`

| Field | Rule |
|---|---|
| `technician_ids[]` | **Required**, 1–10 field technicians of the caller's region (show checkboxes). The **first** is the main technician; all of them can start and submit. A single `technician_id` is still accepted. On a reassign the list replaces the previous team. |
| `scheduled_at` | Required, future `YYYY-MM-DD HH:MM:SS` |
| `instructions` | Optional |

The handover (H2/H3, and the H5 response `data`) has `technician` (main), `second_technician` and **`technicians`** — everyone assigned, main first: `[{ "id", "name", "phone" }]`. Newly added technicians are notified, removed ones are told it was reassigned.

**H6 Submit** `POST /api/staff/handover-reports/{id}/submit` (`multipart/form-data`): `checklist[<key>]` = `yes` / `no` for every item from H1 (required), `customer_ack_name` and `customer_signature` (image) required; `lift_model`, `no_of_stops`, `site_address`, `warranty_start_date`, `warranty_end_date` (default: handover date + 1 year), `technician_time_in`, `technician_time_out`, `technician_signature` (image), `remarks`, `latitude`, `longitude` optional.

## Incentive (I1–I3)

The technician's own monthly incentive — the same figures as the web **My Incentive** page and dashboard card. Targets (KPIs) and amounts are set by the management on the web; progress is counted live from the technician's completed work until the month is sent for approval, then it is frozen.

**I1** `GET /api/staff/incentives/summary?month=2026-11-01` (`month` optional, any date in the month; default this month)

```json
{
  "month": "2026-11-01",
  "month_label": "November 2026",
  "maximum_amount": 6000,
  "earned_amount": 2500,
  "additional_amount": 500,
  "eligible_amount": 3000,
  "remaining_amount": 3500,
  "approval_status": "calculated",
  "approval_status_label": "Calculated",
  "payment_status": "unpaid",
  "paid_at": null,
  "payment_reference": null,
  "kpi_progress": [
    { "kpi_key": "installations", "label": "Installations completed", "target": 4, "completed": 2, "incentive_amount": 4000, "earned_amount": 2000, "additional_earned": 0, "achieved": false },
    { "kpi_key": "site_visits", "label": "Site visits", "target": 5, "completed": 5, "incentive_amount": 2000, "earned_amount": 500, "additional_earned": 500, "achieved": true }
  ]
}
```

Values above are an example; the KPIs (`kpi_key`, `label`, targets, amounts) come from the criteria the management sets for the technician's role. Show `eligible_amount` as the headline, each KPI as "completed / target" with a progress bar, and the status. `approval_status`: `calculated`, `pending_l1` (reporting head), `approved_l1`, `pending_l2` (MD), `approved_l2`, `pending_l3` (accounts), `disbursed`, `rejected` — use `approval_status_label` for display. `payment_status` is `paid` once disbursed.

**I2** `GET /api/staff/incentives/history?per_page=20`

```json
{
  "data": [
    { "id": 14, "month": "2026-10-01", "month_label": "October 2026", "total_amount": 4500, "base_amount": 4000, "additional_amount": 500,
      "status": "disbursed", "status_label": "Disbursed", "paid_at": "2026-11-05T10:00:00+05:30", "payment_reference": "NEFT-1234" }
  ],
  "meta": { "current_page": 1, "last_page": 1, "total": 1 }
}
```

**I3** `GET /api/staff/incentives/{id}` → `{ "incentive": { …I2 fields, "kpi_progress": [ …as in I1 ], "approval_status", "approval_status_label", "payment_status", "rejection_reason", "approvers": [{ "level": 1, "name", "at", "remarks" }, { "level": 2, … }, { "level": 3, … }], "approval_history": [{ "level", "approver", "action", "remarks", "at" }] } }`. Another person's incentive returns `403`.

## 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.
