# Annkut 2026 — Frontend Build Guide

Task-oriented companion to [`API_CONTRACT.md`](./API_CONTRACT.md).

- **This file** — what to build, screen by screen, and which calls each screen makes.
- **`API_CONTRACT.md`** — the reference: every endpoint, request and response.
- **`annkut-api-client.js`** — drop-in client; copy to `src/api/annkut.js`.

Backend is live and tested. Current data: **1,141 sevaks**, **735 parivars**,
**42 mandals**, **4 areas** (BH01–03 + Yuva Pravrutti).

---

## 0. Setup

```js
// src/api/annkut.js  ← copy annkut-api-client.js here
export const BACKEND_ENDPOINT = "http://localhost:8080/index.php/";
```

Wire the 401 handler once, at app start:

```js
import api, { PERM, can, num } from "./api/annkut";
api.onUnauthorized = () => router.push("/login");
```

**Run the dev server on port 3000 or 5173.** Any other origin gets no CORS
header and every call fails. (Adding one is a one-line backend change — ask.)

### Three rules that will bite you otherwise

1. **Numbers come back as strings.** `sevak.id` is `"359"`, not `359`; `target_forms`
   is `"0"`. Use `num()` before any arithmetic or `===`. The exception is
   `parivar.members[]`, where they are real numbers.
2. **`mandal`, `xetra`, `parivar` can be `null`** — sants and senior karyakars
   hold a post but belong to no mandal family. Always `sevak.mandal?.name ?? "—"`.
3. **401 ≠ 403.** 401 means sign out and go to login. 403 means "you may not do
   that" — show the message, stay put. `ApiError` exposes `.isAuth` and `.isForbidden`.

---

## 1. Login screen

```
Sevak ID      [ ASNK001        ]
Password      [ ••••••••       ]
              [ Sign in ]
              Forgot password?
```

```js
const sevak = await api.login(sevakId, password);
localStorage.setItem("sevak", JSON.stringify(sevak));   // token is stored by the client
if (sevak.must_change_password) router.push("/change-password");
else router.push("/");
```

The login response is the whole session: identity, family, mandal, xetra, posts
and permissions. **Store it** — the home screen needs no further calls to render.

Errors: `401` wrong credentials (same message whether the ID or the password is
wrong — do not try to distinguish), `400` a field is empty.

### Forgot password (no token needed)

```
Sevak ID        [ ASNK001     ]
Phone number    [ 9898509128  ]
New password    [ ••••••••    ]   min 6 characters
```

```js
await api.forgotPassword(sevakId, phone, newPassword);   // then send them to /login
```

`404` — *"Sevak ID and phone number do not match our records."*

> ⚠️ **64 accounts have the placeholder number `1234567890`** (59 had no mobile;
> 5 were typed in that way). They can technically reset with it, but treat it as
> "no number on file" — never display it as a contact number, and prompt for a
> real one. If someone truly cannot get in, an **admin can reset it** (§6).

---

## 2. Change password (forced on first login)

Every one of the 1,141 accounts starts on `annkut@2026` with
`must_change_password: true`. Gate the app on it.

```js
await api.changePassword(current, next);   // min 6 chars
router.push("/login");                      // ALL tokens are revoked, including this one
```

That last line matters: the call that changes the password also invalidates the
session that made it. Send them back to sign in.

---

## 3. Home screen — the main one

```
┌──────────────────────────────────────────────────────────┐
│  Annkut Sevak 2026  (MA004)          ← sevak.parivar.code│
│                                                          │
│  Target 25          Filled 8                             │
│                                                          │
│  ┌────────────┐ ┌───────────┐ ┌────────────┐             │
│  │Prabhatsinh │ │  Prayosa  │ │ Thakorbhai │  …          │
│  │ ASMA009 ●  │ │  ASMA010  │ │  ASMA012   │             │
│  └────────────┘ └───────────┘ └────────────┘             │
│     ↑ self, selected by default                          │
│                                                          │
│  Seva list for the SELECTED tab          [ + Add Seva ]  │
└──────────────────────────────────────────────────────────┘
```

**Header** — `Annkut Sevak 2026 (${sevak.parivar.code})`. Hide the bracket when
`parivar` is null.

**Tabs** — straight from the stored login response, no call needed:

```js
const tabs = sevak.parivar?.members ?? [];
const [selected, setSelected] = useState(tabs.find(m => m.is_self) ?? tabs[0]);
```

Each member carries `sevak_code`, `full_name`, `target_forms`, `filled_forms`,
`collected_amount`, `is_self`. Tab count is whatever the family has — 1 to 7 in
the current data. Show name + Sevak ID on each tab.

**Seva list for the selected tab:**

```js
const { seva, total } = await api.sevaFor(selected.sevak_code);
```

Selecting your own tab shows yours; selecting your father's shows his. Every
family member sees every other member's entries.

**After adding a seva**, refresh the counters and the list:

```js
const { members } = await api.family();      // updated target/filled per member
const { seva }    = await api.sevaFor(selected.sevak_code);
```

---

## 4. Add Seva form

```
Book          [ 9001  ▾ ]      ← dropdown, family's books
Receipt no.   [ 3       ]      ← pre-filled, editable
Sahyogi
  Surname     [ PATEL       ]
  First name  [ RAMESH      ]
  Father name [ KANTIBHAI   ]
  Phone       [ 9812345670  ]
Amount        ( ) 500  ( ) 1000  ( ) Other [____]
              [ Submit ]
```

**Book dropdown** — the family's books. Same list for every tab, because a book
belongs to the household, not a person:

```js
const books = await api.myBooks();               // → [{ id, book_no, next_receipt_no, remaining, … }]
const defaultReceiptNo = books[0]?.next_receipt_no;
```

**Finished books are already filtered out.** `books` only ever contains books
with a blank page left, so the dropdown can never offer one that would reject
the form after the user has filled it in. Nothing to check on your side.

If the family's book is finished it comes back under `full_books` instead —
worth a small notice, so the book does not just vanish from the screen:

```js
const { books, full_books } = await api.myBooksWithFull();

if (!books.length && full_books.length) {
  // "Book 9001 is finished. Hand it to your Sanchalak to get another."
}
```

The Sanchalak's mandal view (`api.books(mandalId)`) still lists finished books —
he needs to see them to collect and submit them. Only the family's Add Seva
dropdown hides them.

**Submit** — `onBehalfOf` is the selected tab, which is what credits the seva to
the right person:

```js
await api.addSeva({
  book_id: chosenBook.id,
  receipt_no: receiptNo,
  onBehalfOf: selected.sevak_code,     // omit to credit yourself
  sahyogi_surname, sahyogi_first_name, sahyogi_middle_name, sahyogi_number,
  seva_amount: amount,                 // 500 | 1000 | whatever "Other" collected
});
```

**Do not send** `prasad_type`, `payment_method`, `notes`, `mandal_id` or
`sevak_id`. The server defaults them (`annkut_sevak` / `cash` / null) and takes
the mandal from the book.

Errors worth handling by name:

| Code | Meaning | Suggested message |
|---|---|---|
| 409 | receipt number already used in that book | "Receipt 3 is already recorded." |
| 422 | number outside the book's range | server message names the valid range |
| 403 | the book is not your parivar's | "This book is not issued to your family." |

Three name boxes rather than one is deliberate — sevaks never agree on the order
when given a single field.

---

## 5. Roles and what each one sees

Four tiers. Drive the UI off `sevak.access`; the server re-checks everything.

| | ADMIN (2) | SANCHALAK (35) | Sant/Sah Nirdeshak (13) | Sevak (1,126) |
|---|---|---|---|---|
| Sees | all 42 mandals | own mandal | own xetra/mandals | self + family |
| Add seva | ✅ | ✅ | ✅ | ✅ |
| Edit sevak name/mobile/pankh/target | ✅ | ✅ own mandal | ❌ | ❌ |
| Issue / take back a book **in the mandal** | ✅ | ✅ own mandal | ❌ | ❌ |
| Submit a book into the office | ✅ | ❌ | ❌ | ❌ |
| Issue a **SUBMITTED** book, move it between mandals | ✅ | ❌ | ❌ | ❌ |
| Add / delete a book | ✅ | ❌ | ❌ | ❌ |
| Add / deactivate a sevak | ✅ | ❌ | ❌ | ❌ |
| Edit / void a seva | ✅ | ❌ | ❌ | ❌ |
| Reset someone's password | ✅ | ❌ | ❌ | ❌ |

```js
if (can(sevak, PERM.SEVAK_EDIT))    showEditSevakButtons();     // ADMIN + SANCHALAK
if (can(sevak, PERM.BOOK_ASSIGN))   showAssignAndTakeBack();    // ADMIN + SANCHALAK
                                                                //   (not on SUBMITTED rows)
if (can(sevak, PERM.BOOK_MANAGE))   showAddDeleteBookButtons(); // ADMIN
if (can(sevak, PERM.SEVA_MANAGE))   showEditVoidSevaButtons();  // ADMIN
if (can(sevak, PERM.RESET_PASSWORD))showResetPasswordButton();  // ADMIN
if (sevak.access.global || sevak.access.mandal_count > 0) showMandalMenu();
```

**An ordinary sevak has two write powers: record a seva, and change their own
password.** Everything else is read-only. If `mandal_count` is 0 and `global` is
false, skip the mandal/reports navigation entirely — that is 1,126 of 1,141 users.

Two distinct 403 messages come back, so tell the user the right thing:

- *"Only a Sanchalak or administrator can change sevak details."* → wrong role
- *"That is outside the mandals you look after."* → right role, wrong mandal

---

## 6. Management screens

Only build these behind the permission checks above.

### Mandal dashboard — anyone with scope

```js
const { mandal_array, target } = await api.mandals({ year: 2026 });
```

Each row: `code`, `name`, `area_code`, `target_forms`, `filled_forms`,
`collected_amount`, `parivars`, `sevaks`. `target` has org-wide totals for the
mandals in scope.

### Sevak list — Sanchalak and Admin

```js
const { sevak, total } = await api.sevaks({ mandal_id, q, limit: 50, offset: 0 });
```

`total` is the count before `limit`, and now respects **every** filter including
`pankh` and `area_id` — safe to page on.

Editable fields (ADMIN + SANCHALAK, own mandal only):

```js
await api.editSevak(sevakCode, { surname, first_name, middle_name, mobile, pankh });
await api.setSevakTarget(sevakCode, 25);
```

Pankh options: `S` Sanyukt · `M` Mahila · `YK` Yuvak · `YT` Yuvati · `BL` Bal ·
`BK` Balika. **19 sevaks have `pankh: null`** — render as "—", do not default.

### Receipt books — the circulation screen

A book is held by a **parivar**, so the picker lists families, not people
(`api.parivars(mandalId)` → `{ id, code, mandal_name, members }`).

A Sanchalak circulates books **inside his own mandal**: give a book out, take it
back, give it to the next family. That is where his authority ends. Submitting a
book takes it into the office and out of his hands - only an ADMIN can do that,
and only an ADMIN can place it again afterwards, in any mandal. Creating and
deleting stock is ADMIN too.

```js
const { all_books } = await api.books(mandalId);
await api.assignBook(bookId, { parivarId });   // ADMIN + SANCHALAK ... unless SUBMITTED
await api.deassignBook(bookId, lastUsedNo);    // ADMIN + SANCHALAK
await api.submitBook(bookId);                  // ADMIN only
await api.createBook({ mandal_id, book_no, pages });  // ADMIN only
```

**A `SUBMITTED` book is with the office.** `assign` on one needs ADMIN; a
Sanchalak gets **403** *"This book is with the office. Only an administrator can
issue it."* So hide Assign on SUBMITTED rows unless `access.is_admin`.

#### Add-book form — page size is a radio, not a free field

```
Mandal        [ Manglaya  ▾ ]
Book number   [ 9003        ]
Pages         (•) 25 pages    ( ) 10 pages
              [ Add book ]
```

```js
await api.createBook({ mandal_id: mandalId, book_no: bookNo, pages });  // 25 or 10
```

Receipt books are printed in two sizes only. The **receipt range is derived**
from the choice — `pages: 25` gives receipts 1–25, `pages: 10` gives 1–10 — so a
10-page book can never be set up to accept 25 receipts. Default the radio to
**25**; omitting `pages` also gives 25.

Any other value is **422** — *"A receipt book has 25 or 10 pages."* Since it is
a radio, that should be unreachable from the UI; treat it as a bug if you see it.

**Book numbers are unique across the whole organisation**, not just within a
mandal — the number is printed on the cover. A number another mandal already
holds returns **409** naming it:

> Book number 2 is already assigned to Akshardham mandal.

Show that message as-is under the book-number field; it tells the admin exactly
where to look. The same check runs when renaming a book. Deleting a book frees
its number for reuse.

The size is enforced all the way through: on a 10-page book, receipt 10 saves and
receipt 11 returns **422** *"Receipt number must be between 1 and 10."*

#### One button that flips

Each book row tells you which state it is in — **`parivar_id` is null when the
book is free**:

```
Book 9001   ·  free                                  [ Assign ]
Book 9002   ·  MA004  ·  used 7 / 50  ·  next 8      [ Take back ]
```

```jsx
{book.parivar_id
  ? <button onClick={() => confirmDeassign(book)}>Take back</button>
  : <button onClick={() => confirmAssign(book)}>Assign</button>}
```

Useful fields on each row: `parivar_id`, `parivar_code`, `book_no`, `status`,
`last_used_no`, `next_receipt_no`, `remaining`.

#### A book's life

```
AVAILABLE ──assign──> ISSUED ──deassign──> AVAILABLE   Sanchalak or ADMIN
                        │                             (inside one mandal)
                     submit                        ADMIN only
                        │
        receipts left ──┼──> SUBMITTED     ADMIN only: assign anywhere,
                        │                       or transfer to another mandal
         none left    ──┴──> CLOSED  🔒 locked for everyone, forever
```

**Submit** is different from **take back**, and only an ADMIN may do it. Take
back is the Sanchalak keeping the book in his mandal for the next family. Submit
hands it to the office, which can then place it anywhere - and from that point
the Sanchalak can no longer issue it.

```js
const r = await api.submitBook(bookId);   // ADMIN only
// r.status === "SUBMITTED"  → r.remaining receipts left, office can redistribute
// r.status === "CLOSED"     → full; it will never be issued again
```

Show the outcome, because the two are very different:

> *"Book submitted to the office with 6 receipts left. It can be issued to any mandal."*
> *"Book fully used and closed. It cannot be issued again."*

**A part-used book keeps its count wherever it goes.** Submitted at receipt 4 of
10, it arrives in the next mandal with `next_receipt_no: 5`. Receipts already
written keep the mandal that collected them — moving a book never rewrites
history.

**CLOSED is a hard lock.** Assign, transfer and submit all return **409** for
everyone, administrators included — there is no blank page left. Render those
rows greyed out with no action buttons at all.

#### The office pool — ADMIN

```js
const books = await api.bookPool();                  // no family, receipts left
await api.transferBook(bookId, targetMandalId);      // ADMIN only
```

```
Book   From        Left   Next
44     Manglaya     6      5     [ Send to… ▾ ]
75     Narayan K.  25      1     [ Send to… ▾ ]
```

`pool` lists every book across your scope that no family is holding and that
still has pages — exactly the stock worth redistributing. Closed books never
appear. `transfer` returns **409** if the book is closed, still with a parivar
("take it back first"), or already in the chosen mandal.

#### Confirmation before taking a book back

```
┌──────────────────────────────────────────────┐
│  Take back book 9002?                        │
│                                              │
│  Currently with parivar MA004.               │
│  Receipts used so far: [ 7 ]                 │
│                                              │
│  MA004 will not be able to add any more seva │
│  in this book.                               │
│                                              │
│              [ Cancel ]  [ Take back ]       │
└──────────────────────────────────────────────┘
```

Ask for `last_used_no` in the dialog — it is how many receipts the family
actually wrote, and the next family continues from there. **The count carries
over**: hand a book back at 7 and the next parivar starts at 8, not 1.

The server refuses a value lower than what is already recorded (**422**), so
show that message rather than swallowing it.

---

## 6b. Confirmation dialogs

Three actions are hard to undo. Each needs an explicit confirm, and each should
name the person or thing affected — never a bare "Are you sure?".

| Action | Who | Must say |
|---|---|---|
| Take a book back | ADMIN + SANCHALAK | which parivar loses it, and ask for `last_used_no` |
| Reset a password | ADMIN | the sevak's name, and that their sessions end |
| Deactivate a sevak | ADMIN | the sevak's name, and that their login stops |

**Reset password** — the new password is shown **once**, in the response. Put it
somewhere copyable and make that obvious; there is no way to retrieve it later.

```
┌───────────────────────────────────────────────┐
│  Reset password for Gohil Prayosa (ASMA010)?  │
│                                               │
│  ( ) Reset to the default annkut@2026         │
│  (•) Set a password  [ temp12345          ]   │
│                                               │
│  They will be signed out everywhere.          │
│              [ Cancel ]  [ Reset password ]   │
└───────────────────────────────────────────────┘

  → then:  New password for ASMA010:  temp12345   [copy]
           Shown once. Give it to the sevak now.
```

**Deactivate a sevak** — reversible in the database, but their login stops and
they leave the mandal list. Their past seva entries are untouched.

```
┌──────────────────────────────────────────────┐
│  Deactivate Gohil Prayosa (ASMA010)?         │
│                                              │
│  They will not be able to sign in, and will  │
│  be removed from the mandal list.            │
│  Seva already recorded is kept.              │
│                                              │
│            [ Cancel ]  [ Deactivate ]        │
└──────────────────────────────────────────────┘
```

Note: an admin cannot deactivate their **own** account — the server returns
**422**. Hide the option on your own row.

### Reset a password — ADMIN only

For a sevak who is locked out and whose number is the placeholder.

```js
const r = await api.resetPassword(sevakCode, "temp12345");  // omit to reset to annkut@2026
alert(`New password for ${r.sevak_id}: ${r.password}`);
```

`r.password` is shown **once** — nothing is stored readable. Display it clearly
so the admin can pass it on; there is no second chance. The reset is recorded in
`activity_log`, and that sevak's sessions are revoked.

---

## 7. Build checklist

```
[ ] BACKEND_ENDPOINT set; dev server on :3000 or :5173
[ ] api.onUnauthorized → /login
[ ] Login stores the whole `sevak` object, not just the token
[ ] must_change_password gate before the app
[ ] Forgot-password screen (Sevak ID + phone + new password)
[ ] Header shows "Annkut Sevak 2026 (PARIVAR_ID)"
[ ] Family tabs from parivar.members, self first, dynamic count
[ ] Tab switch → sevaFor(member)
[ ] Add Seva sends on_behalf_of = selected tab
[ ] Book dropdown from myBooks(), receipt no pre-filled (full books already excluded)
[ ] Notice when the family has only full_books left
[ ] Amount 500 / 1000 / Other
[ ] num() used before every calculation
[ ] null guards on mandal / xetra / parivar
[ ] 403 shows the server message, does NOT sign out
[ ] Management buttons hidden behind can(sevak, PERM.*)
[ ] Book row toggles Assign / Take back on book.parivar_id
[ ] Confirm dialog on take-back, asking for last_used_no
[ ] Submit shows SUBMITTED-vs-CLOSED outcome clearly
[ ] CLOSED books greyed out, no action buttons
[ ] Office pool screen with Send-to-mandal (ADMIN)
[ ] Confirm dialog on reset password; new password shown once, copyable
[ ] Confirm dialog on deactivate sevak; hidden on your own row
```

---

## 8. Test accounts

All on `annkut@2026`.

| Sevak ID | Role | Good for testing |
|---|---|---|
| `ADMIN26` | Admin | every management screen |
| `RKMA001` | Sanchalak, BH02/MA | edit + assign inside one mandal; 403 outside it |
| `AGSP004` | Sah Nirdeshak | read-only across 6 mandals — edit buttons must be hidden |
| `ASMA009` | Ordinary sevak | the main flow; family of 7, so 7 tabs |
| `SNBH001` | Kothari | global read + admin |

`ASMA009`'s family (parivar `MA004`) is the best end-to-end case: log in as him,
switch to `ASMA012`'s tab, add a seva, then log in as `ASMA012` and confirm it
appears under his name.
