# Update 12 Aug 2026 — web panel

Everything below is on the web panel. The Android app is unchanged and needs
no new build.

**Run `update_120826.sql` after `update_090826_v2.sql`**, and take a backup
first. Nothing in this round drops a table, drops a column, or changes a value
you already have — it adds one column and one index to `depots` and creates two
new tables, and it is safe to run twice.

There is **one thing to do by hand** after importing: mark the depot that
receives your purchases as the **Main Store**. Section 3 explains why, and the
SQL file repeats it at step 5.

---

## 1. Attendance selfies and locations were invisible

Check-in and check-out selfies were being taken on the phone, uploaded, and
stored correctly. The panel then built the image URL through
`public/storage` — the symlink this host refuses to follow — so every one of
them came back as a broken image. The same route the shop photos and profile
pictures already use now serves them, and they appear.

That was only half of it. The table had **one** photo column and **one**
location column, and both were wired to the check-in half. A check-out selfie
was captured on the phone and then never seen by anyone, and where somebody
checked out from was never shown at all.

The two events now each get their own cell, carrying everything recorded with
them:

- the selfie, as a thumbnail that opens full size
- the time, with the late badge on check-in
- the coordinates, **printed** rather than hidden behind an icon, and still
  linking to Google Maps
- the address the phone reported, when it sent one
- the mock-GPS warning and the "outside depot" flag

A missing selfie now draws a marked placeholder instead of a dash, so "no
photo was taken" and "the photo will not load" are no longer the same thing on
screen.

Two more places were building URLs the same broken way and are fixed with it:
the **leave application attachment**, and the **photos on the sales tree**.

---

## 2. Doctor and chemist approvals are separate pages

They arrived in one list, sorted by time, with a type badge on each row. A
reviewer chasing one doctor read past every chemist to find it, and the columns
actually worth seeing could not both fit — a doctor's chamber, institute and
qualifications are not a chemist's address, proprietor and drug licence.

There are now two pages, **Doctor Approvals** and **Chemist Approvals**, each
with its own sidebar entry and its own pending badge. Each keeps the
Pending / Approved / Rejected tabs and the reject-with-reason flow exactly as
before, and each shows the columns that matter for that type. The two counters
at the top of either page switch between them in one click.

Permissions are enforced per page: `doctor.approve` opens the doctor queue,
`chemist.approve` the chemist one. Someone holding only one of the two never
sees the other. The old `/approvals` address still works — it forwards to
whichever queue the signed-in reviewer may act on.

**The captured location is now readable.** It was a map pin with nothing beside
it, so a reviewer could not tell a good fix from a bad one without opening a
tab per row. The coordinates are printed on the row, and still link to the map.
Rows with no fix at all keep the red **No GPS** badge.

---

## 3. One main stock, distributed to the depots

This is the substantial change, and it needs the one manual step.

### What was wrong

Goods are received centrally and then handed out to the depots that sell them.
The system had no way to say that. Every stock row was a depot row, so a
purchase landed straight in whichever depot the buyer happened to pick, and
there was nothing in between. There was no central holding, no record of what
had been sent where, and no way to compare depots.

### The main store

One depot now carries a **Main Store** flag, set from Settings → Depots. It
holds stock like any depot but sells nothing, so it is kept out of the order,
invoice, territory and staff-posting selectors — offering it there would put
sales against a place with no customers. It **is** offered on the stock
screens: Stock In, Purchases, Stock Overview, Stock Movements, the Stock report
and the Expiry report.

Modelling it as a depot rather than a new kind of place is deliberate. Batches,
movements, first-expiry-first-out deduction, expiry alerts and the stock report
all key on the depot already and mean exactly the same thing for the central
store. A parallel warehouse table would have needed every one of them rewritten
to look in two places.

Only one depot may hold the flag. Marking a second releases the first
automatically.

### Distributing

**Stock → Stock Distribution → New Distribution.** Pick the destination depot
and a date, then type quantities against the batches you are sending. The form
lists what the main store actually holds, nearest expiry first, so you cannot
promise a depot something that is not there.

Each distribution is one document with a transfer number, and it is all or
nothing: if the last line is short, nothing moves. The quantities go through
the same stock service everything else uses, so the movement log and the
per-batch stock rows stay the single source of truth — the distribution list is
the paperwork over the top of them.

Reading the distribution history needs `stock.view`. Sending goods out needs
`stock.manage`.

Stock In now opens on the main store, since receiving centrally is the normal
case and putting stock straight into a selling depot is the exception.

---

## 4. Depot Stock & Sales report

**Reports → Depot Stock & Sales.** One row per depot, for whatever date range
you choose:

| Column | What it answers |
| --- | --- |
| Received Qty / Value | what the main store sent it in this period |
| Orders / Sold Qty / Sales Value | what it sold |
| Stock On Hand / Stock Value | what it is still holding, expired batches excluded |
| Low Stock | how many products are at or below their reorder level |

The Low Stock figure is a link straight into that depot's shortage list.
Excel and PDF export as with every other report.

Expired batches are deliberately left out of Stock On Hand. They cannot be
sold, so counting them would hide a depot that has run out behind a shelf of
dead boxes.

---

## 5. Low stock warning

Three ways to see it, so it is not possible to miss:

**A daily notification, 08:00.** Every depot at or below a product's reorder
level is reported to that depot's in-charge and area manager, and to head
office. It goes to the in-app feed and out as a push, like every other
notification. Head office is included because the depot cannot fix the shortage
itself — only a distribution from the main store refills it.

**A badge in the sidebar,** on a new **Low Stock** entry, so it is visible
between two runs of the daily job.

**Stock → Low Stock,** listing every depot and product below its level, worst
first, with the shortfall and a bar showing how far down it is. A depot that
has run to zero is marked in red rather than merely being at the top of the
list, and a row holding stock that has expired says so — that is a depot that
looks stocked and is not.

The reorder level is the product's existing **Min Stock Alert**. Nothing new to
set up: whatever you already entered against each product is what the warning
uses, per depot.

To send one immediately and see who receives it:

```
php artisan notify:low-stock
```

The 08:00 run needs Laravel's scheduler to be active — the same cron line the
existing expiry and due reminders already depend on.

---

## 6. English-only, now enforced on both halves

The rule was checked automatically on the Flutter app and not on the web panel,
which is where most of the text the company reads actually lives. The same test
now scans `ahl-web` as well — PHP, Blade, SQL, JS and CSS. Both pass. A Bengali
character committed to either half from now on fails `flutter test` rather than
reaching a screen.

---

## Files

**Database**

- `database/sql/update_120826.sql` — the import for this delivery
- `database/migrations/2026_08_12_100001_add_main_warehouse_to_depots_table.php`
- `database/migrations/2026_08_12_100002_create_stock_transfers_table.php`

**New**

- `app/Models/StockTransfer.php`, `app/Models/StockTransferItem.php`
- `app/Http/Controllers/Admin/StockTransferController.php`
- `app/Console/Commands/SendLowStockAlerts.php`
- `resources/views/admin/stock-transfers/{index,create,show}.blade.php`
- `resources/views/admin/inventory/low-stock.blade.php`
- `resources/views/admin/reports/depot-performance.blade.php`
- `resources/views/reports/pdf/depot-performance.blade.php`
- `resources/views/admin/approvals/queue.blade.php` (replaces `index.blade.php`)
- `resources/views/admin/attendance/event.blade.php`

**Changed**

- `app/Models/Attendance.php` — selfie and map URL accessors
- `app/Models/Depot.php` — `is_main`, `active()` / `selling()` / `main()` scopes
- `app/Services/StockService.php` — `distribute()`, `lowStock()`
- `app/Services/ReportService.php` — `depotPerformanceReport()`
- `app/Http/Controllers/Admin/PartyApprovalController.php` — split queues
- `app/Http/Controllers/Admin/{Inventory,Depot,Report,LocationLookup}Controller.php`
- `app/Providers/AppServiceProvider.php` — per-type approval badges, low stock badge
- `routes/web.php`, `routes/console.php`
- the depot selectors across the panel and the app's geography endpoint, so the
  main store appears only where it belongs
- `test/no_bangla_test.dart` — now covers the web panel too
