Waseel: driver capture, chat/calls, dispatch, and session fixes

Driver onboarding now photographs the licence, ID card and vehicle
registration and reads the credential fields off them, plus a camera-only
profile selfie riders check the arriving driver against. Adds in-app chat
and WebRTC calls, push-backed ride offers, ratings, cancellation and
payment sheets, settlement, and the owner dashboard endpoints behind them.

Camera permission on Android:
  - Declare CAMERA and READ_MEDIA_IMAGES in the manifest. expo-image-picker's
    own plugin never declares CAMERA, and Android denies a request for an
    undeclared permission instantly and silently — no dialog is ever shown,
    which is indistinguishable from the app not asking at all.
  - Handle canAskAgain: once Android stops showing the dialog, repeating why
    we need it is a dead end, so offer Open Settings instead (lib/capture-
    permission.ts), matching what the location flow already did.

Session: a 401 on a request that carried a token now ends the session
instead of being reinterpreted per-screen — driver-home had been reading it
as "this user has no driver profile" and showing an onboarding form to an
already-onboarded driver. Requests without a token are exempt so a failed
sign-in doesn't sign you out, and the notification is latched per token so
concurrent polls tear the session down once. (root) gains the auth guard
that turns that into the sign-in screen; app/index.tsx only guarded the way
in, leaving a session that ended mid-screen with nowhere to go.

Also ignore .uploads/ — it holds driver licence, ID and vehicle scans plus
profile photos, which are personal data and must not be committed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krikorios
2026-08-26 02:17:55 +03:00
co-authored by Claude Opus 5
parent 1d84003e0a
commit 8807ff41c5
111 changed files with 14568 additions and 1411 deletions
+7
View File
@@ -130,6 +130,13 @@ tr:last-child td {
.badge.paid { color: var(--success); border-color: var(--success); }
.badge.unpaid { color: var(--danger); border-color: var(--danger); }
/* Driver vetting states. Pending has to catch the eye — it is a queue someone
has to work through — while approved stays quiet, being the resting state. */
.badge.pending { color: #f5a524; border-color: #f5a524; }
.badge.approved { color: var(--success); border-color: var(--success); }
.badge.rejected { color: var(--danger); border-color: var(--danger); }
.badge.suspended { color: var(--danger); border-color: var(--danger); }
button,
select,
input {
+26
View File
@@ -48,3 +48,29 @@ export const api = async <T>(
return body as T;
};
/**
* Fetch a binary response — a driver's document scan — as an object URL.
*
* Scans are served from an authenticated route, and an `<img src>` cannot
* carry the bearer token, so the bytes are fetched here and handed to the
* image as a blob URL instead. The caller owns the returned URL and must
* revokeObjectURL it, or the blob is pinned in memory for the tab's life.
*/
export const apiObjectUrl = async (path: string): Promise<string> => {
const headers = new Headers();
if (authToken) headers.set("Authorization", `Bearer ${authToken}`);
const res = await fetch(`${API_URL}${path}`, { headers });
if (res.status === 401) {
clearToken();
throw new ApiError("Session expired. Please sign in again.", 401);
}
if (!res.ok) {
throw new ApiError(`Could not load document (${res.status})`, res.status);
}
return URL.createObjectURL(await res.blob());
};
+573 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { api } from "../lib/api";
import { api, apiObjectUrl } from "../lib/api";
type Driver = {
id: number;
@@ -14,8 +14,33 @@ type Driver = {
car_model: string | null;
total_rides: number;
revenue: number;
approval_status: string;
rejection_reason: string | null;
submitted_at: string | null;
// What the driver typed at onboarding, usually read off the scans below.
license_number: string | null;
license_expiry: string | null;
national_id: string | null;
plate_number: string | null;
// Stored scan names, served through /driver/documents/:name.
license_image_url: string | null;
id_image_url: string | null;
vehicle_reg_image_url: string | null;
};
// What each driver still owes the company, and what the company still owes
// them. Loaded alongside the driver list so an operator can reconcile a shift
// without leaving the page.
type Balance = {
driver_id: number;
name: string;
owes_company_cents: number;
owed_to_driver_cents: number;
unsettled_rides: number;
};
const money = (cents: number) => (cents / 100).toFixed(2);
const EMPTY = {
first_name: "",
last_name: "",
@@ -29,11 +54,31 @@ export default function Drivers() {
const [drivers, setDrivers] = useState<Driver[]>([]);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState<Driver | "new" | null>(null);
const [balances, setBalances] = useState<Record<number, Balance>>({});
// Vetting a driver against their scans. Separate from the edit form: this is
// a decision about whether someone may carry passengers, not a field update.
const [reviewing, setReviewing] = useState<Driver | null>(null);
// Opening the picker rather than settling outright. A driver handing over
// part of what they owe is normal, and settling the whole balance because
// the button only offered all-or-nothing would put the ledger out of step
// with the cash actually received.
const [settleFor, setSettleFor] = useState<{
driver: Driver;
side: "platform_fee" | "driver_payout";
} | null>(null);
const load = useCallback(async () => {
try {
const res = await api<{ data: Driver[] }>("/admin/drivers");
const [res, ledger] = await Promise.all([
api<{ data: Driver[] }>("/admin/drivers"),
api<{ data: Balance[] }>("/admin/settle"),
]);
setDrivers(res.data);
setBalances(
Object.fromEntries(ledger.data.map((b) => [b.driver_id, b])),
);
setError(null);
} catch (e) {
setError((e as Error).message);
@@ -69,9 +114,12 @@ export default function Drivers() {
<th>Service</th>
<th>Seats</th>
<th>Rating</th>
<th>Vetting</th>
<th>Online</th>
<th>Rides</th>
<th>Revenue</th>
<th>Owes company</th>
<th>Owed to driver</th>
<th></th>
</tr>
</thead>
@@ -87,11 +135,53 @@ export default function Drivers() {
</td>
<td>{d.car_seats}</td>
<td>{d.rating}</td>
<td>
<span className={`badge ${d.approval_status}`}>
{d.approval_status}
</span>
</td>
<td>{d.online ? "● online" : "○ off"}</td>
<td>{d.total_rides}</td>
<td>{d.revenue.toLocaleString()}</td>
<td>
{balances[d.id]?.owes_company_cents ? (
<strong>{money(balances[d.id].owes_company_cents)}</strong>
) : (
<span className="muted"></span>
)}
</td>
<td>
{balances[d.id]?.owed_to_driver_cents ? (
<strong>{money(balances[d.id].owed_to_driver_cents)}</strong>
) : (
<span className="muted"></span>
)}
</td>
<td>
<div className="row-actions">
{balances[d.id]?.owes_company_cents ? (
<button
className="secondary"
onClick={() =>
setSettleFor({ driver: d, side: "platform_fee" })
}
>
Collect
</button>
) : null}
{balances[d.id]?.owed_to_driver_cents ? (
<button
className="secondary"
onClick={() =>
setSettleFor({ driver: d, side: "driver_payout" })
}
>
Pay out
</button>
) : null}
<button className="secondary" onClick={() => setReviewing(d)}>
Review
</button>
<button className="secondary" onClick={() => setEditing(d)}>
Edit
</button>
@@ -105,6 +195,29 @@ export default function Drivers() {
</tbody>
</table>
{settleFor && (
<SettlePicker
driver={settleFor.driver}
side={settleFor.side}
onClose={() => setSettleFor(null)}
onSettled={() => {
setSettleFor(null);
load();
}}
/>
)}
{reviewing && (
<VettingPanel
driver={reviewing}
onClose={() => setReviewing(null)}
onDecided={() => {
setReviewing(null);
load();
}}
/>
)}
{editing && (
<DriverForm
initial={editing === "new" ? null : editing}
@@ -119,6 +232,261 @@ export default function Drivers() {
);
}
// One document scan, fetched with the operator's token and rendered from a
// blob URL — the route is authenticated, so a bare <img src> would 401.
function DocumentScan({ name, label }: { name: string; label: string }) {
const [src, setSrc] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let url: string | null = null;
let cancelled = false;
apiObjectUrl(`/driver/documents?name=${encodeURIComponent(name)}`)
.then((objectUrl) => {
url = objectUrl;
// The panel may have closed while the fetch was in flight; revoke
// rather than setting state on an unmounted component.
if (cancelled) URL.revokeObjectURL(objectUrl);
else setSrc(objectUrl);
})
.catch((e) => {
if (!cancelled) setError((e as Error).message);
});
return () => {
cancelled = true;
if (url) URL.revokeObjectURL(url);
};
}, [name]);
return (
<figure style={{ margin: 0 }}>
<figcaption className="muted" style={{ fontSize: 12, marginBottom: 4 }}>
{label}
</figcaption>
{error ? (
<div className="error">{error}</div>
) : src ? (
// Opens full size in a tab: small print on a licence is unreadable at
// thumbnail size, and reading it is the whole point of this panel.
<a href={src} target="_blank" rel="noreferrer">
<img
src={src}
alt={label}
style={{
width: "100%",
maxHeight: 220,
objectFit: "contain",
background: "#00000010",
borderRadius: 8,
}}
/>
</a>
) : (
<div className="muted">Loading</div>
)}
</figure>
);
}
// The driver's profile photo. Unlike a document scan this route is public, so
// the browser can load it straight from a <src> — a stored name is resolved
// through the API, while an external URL an owner typed in is used as-is.
function DriverPhoto({ name }: { name: string }) {
const src = /^https?:/i.test(name)
? name
: `${import.meta.env.VITE_API_URL ?? ""}/driver/photo?name=${encodeURIComponent(name)}`;
return (
<img
src={src}
alt="Driver profile photo"
style={{
width: 72,
height: 72,
borderRadius: "50%",
objectFit: "cover",
background: "#00000010",
}}
/>
);
}
/**
* Check what a driver typed against the documents they photographed, then
* approve or reject.
*
* The scans exist precisely because the typed numbers arrive from OCR and OCR
* is fallible — so the two are shown side by side and the decision rests on
* the document, not on the field. Rejecting requires a reason, which is what
* the driver sees in the app and corrects against.
*/
function VettingPanel({
driver,
onClose,
onDecided,
}: {
driver: Driver;
onClose: () => void;
onDecided: () => void;
}) {
const [reason, setReason] = useState(driver.rejection_reason ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const decide = async (
approval_status: "approved" | "rejected" | "suspended",
) => {
if (approval_status !== "approved" && !reason.trim()) {
setError("Give the driver a reason they can act on.");
return;
}
setBusy(true);
setError(null);
try {
await api(`/admin/drivers/${driver.id}`, {
method: "PATCH",
body: JSON.stringify({
approval_status,
rejection_reason:
approval_status === "approved" ? undefined : reason.trim(),
}),
});
onDecided();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
};
const scans: [string | null, string][] = [
[driver.license_image_url, "Driving licence"],
[driver.id_image_url, "ID card"],
[driver.vehicle_reg_image_url, "Vehicle registration"],
];
const present = scans.filter(([name]) => name);
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>
Vetting {driver.first_name} {driver.last_name} (#{driver.id})
</h3>
{/* The photo riders will actually see. It is checked here rather than
left to chance because it is the one part of the profile shown to
every passenger before they get into the car. */}
{driver.profile_image_url && (
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<DriverPhoto name={driver.profile_image_url} />
<span className="muted" style={{ fontSize: 12 }}>
Shown to riders choosing a driver
</span>
</div>
)}
<p className="muted" style={{ marginTop: 0 }}>
Status: <strong>{driver.approval_status}</strong>
{driver.submitted_at
? ` · submitted ${new Date(driver.submitted_at).toLocaleString()}`
: ""}
</p>
<table>
<tbody>
<tr>
<td>Licence number</td>
<td>
{driver.license_number ?? <span className="muted"></span>}
</td>
</tr>
<tr>
<td>Licence expiry</td>
<td>
{driver.license_expiry ? (
driver.license_expiry.slice(0, 10)
) : (
<span className="muted"></span>
)}
</td>
</tr>
<tr>
<td>National ID</td>
<td>{driver.national_id ?? <span className="muted"></span>}</td>
</tr>
<tr>
<td>Plate</td>
<td>{driver.plate_number ?? <span className="muted"></span>}</td>
</tr>
<tr>
<td>Car</td>
<td>{driver.car_model ?? <span className="muted"></span>}</td>
</tr>
</tbody>
</table>
{present.length > 0 ? (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
gap: 12,
margin: "12px 0",
}}
>
{present.map(([name, label]) => (
<DocumentScan key={name} name={name as string} label={label} />
))}
</div>
) : (
<div className="muted" style={{ margin: "12px 0" }}>
No scans on file this profile predates document capture.
</div>
)}
<input
placeholder="Reason (required to reject or suspend)"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
{error && <div className="error">{error}</div>}
<div className="row-actions">
<button disabled={busy} onClick={() => decide("approved")}>
{busy ? "Saving…" : "Approve"}
</button>
<button
type="button"
className="danger"
disabled={busy}
onClick={() => decide("rejected")}
>
Reject
</button>
{driver.approval_status === "approved" && (
<button
type="button"
className="danger"
disabled={busy}
onClick={() => decide("suspended")}
>
Suspend
</button>
)}
<button type="button" className="secondary" onClick={onClose}>
Close
</button>
</div>
</div>
</div>
);
}
function DriverForm({
initial,
onClose,
@@ -206,3 +574,206 @@ function DriverForm({
</div>
);
}
// Pick exactly which rides a payment covers.
//
// Settling is an assertion about the real world — that cash was handed over,
// or a transfer was made — so the operator has to be able to say precisely
// which trips it accounts for. Everything is selected by default, because
// settling the whole balance is still the common case; unticking is the
// exception, not the workflow.
type UnsettledRide = {
ride_id: number;
amount_cents: number;
fare_price: number;
origin_address: string;
destination_address: string;
completed_at: string;
};
function SettlePicker({
driver,
side,
onClose,
onSettled,
}: {
driver: Driver;
side: "platform_fee" | "driver_payout";
onClose: () => void;
onSettled: () => void;
}) {
const [rides, setRides] = useState<UnsettledRide[]>([]);
const [picked, setPicked] = useState<Set<number>>(new Set());
const [note, setNote] = useState("");
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const collecting = side === "platform_fee";
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await api<{ data: { rides: UnsettledRide[] } }>(
`/admin/settle?driver_id=${driver.id}&side=${side}`,
);
if (cancelled) return;
setRides(res.data.rides);
setPicked(new Set(res.data.rides.map((r) => r.ride_id)));
} catch (e) {
if (!cancelled) setError((e as Error).message);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [driver.id, side]);
const toggle = (rideId: number) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(rideId)) next.delete(rideId);
else next.add(rideId);
return next;
});
const allPicked = rides.length > 0 && picked.size === rides.length;
const total = rides
.filter((r) => picked.has(r.ride_id))
.reduce((sum, r) => sum + r.amount_cents, 0);
const submit = async () => {
if (picked.size === 0) return;
setBusy(true);
try {
await api("/admin/settle", {
method: "POST",
body: JSON.stringify({
side,
// Ride ids, not driver_id: the server settles exactly these and
// leaves the rest of the balance outstanding.
ride_ids: [...picked],
note: note.trim() || undefined,
}),
});
onSettled();
} catch (e) {
setError((e as Error).message);
setBusy(false);
}
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>
{collecting ? "Collect commission from" : "Pay out"}{" "}
{driver.first_name} {driver.last_name}
</h3>
<p className="muted" style={{ marginTop: -6 }}>
{collecting
? "Cash rides where this driver still owes the platform fee."
: "Card rides where the platform still owes this driver."}
</p>
{error ? <p className="error">{error}</p> : null}
{loading ? (
<p className="muted">Loading rides</p>
) : rides.length === 0 ? (
<p className="muted">Nothing outstanding.</p>
) : (
<>
<label style={{ display: "block", margin: "8px 0" }}>
<input
type="checkbox"
checked={allPicked}
onChange={() =>
setPicked(
allPicked
? new Set()
: new Set(rides.map((r) => r.ride_id)),
)
}
/>{" "}
Select all ({rides.length})
</label>
<div style={{ maxHeight: 260, overflowY: "auto" }}>
<table>
<thead>
<tr>
<th></th>
<th>Ride</th>
<th>Route</th>
<th>Fare</th>
<th>{collecting ? "Commission" : "Payout"}</th>
</tr>
</thead>
<tbody>
{rides.map((r) => (
<tr key={r.ride_id}>
<td>
<input
type="checkbox"
checked={picked.has(r.ride_id)}
onChange={() => toggle(r.ride_id)}
/>
</td>
<td>#{r.ride_id}</td>
<td style={{ fontSize: 11 }}>
{r.origin_address} {r.destination_address}
<div className="muted">
{new Date(r.completed_at).toLocaleDateString()}
</div>
</td>
<td>{money(r.fare_price)}</td>
<td>
<strong>{money(r.amount_cents)}</strong>
</td>
</tr>
))}
</tbody>
</table>
</div>
<input
placeholder="Reference (transfer id, receipt no., 'cash in office')"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
<p>
<strong>
{picked.size} of {rides.length} ride
{rides.length === 1 ? "" : "s"} · {money(total)}
</strong>
</p>
</>
)}
<div className="row-actions">
<button className="secondary" onClick={onClose} disabled={busy}>
Cancel
</button>
<button
onClick={submit}
disabled={busy || picked.size === 0}
title={
picked.size === 0 ? "Select at least one ride" : undefined
}
>
{busy
? "Recording…"
: collecting
? `Mark ${money(total)} collected`
: `Mark ${money(total)} paid`}
</button>
</div>
</div>
</div>
);
}
+108 -12
View File
@@ -8,9 +8,19 @@ type Ride = {
ride_time: number;
fare_price: number;
payment_status: string;
status: string;
cancelled_by: string | null;
cancellation_reason: string | null;
platform_fee_cents: number | null;
driver_payout_cents: number | null;
commission_rate: string | number | null;
platform_fee_settled_at: string | null;
driver_payout_settled_at: string | null;
created_at: string;
completed_at: string | null;
user_email: string;
driver: { driver_id: number; name: string; rating: number };
// Null for a ride that was cancelled or expired before a driver was matched.
driver: { driver_id: number; name: string; rating: number } | null;
};
type RidesResponse = {
@@ -23,6 +33,21 @@ type RidesResponse = {
const fmt = (n: number) => n.toLocaleString();
// Money is stored in cents; the table shows currency units.
const money = (cents: number | null | undefined) =>
cents == null ? "—" : (cents / 100).toFixed(2);
// A cancelled or expired ride earns nobody anything, so the split columns show
// a dash rather than a zero — "no money changed hands here" and "the fee
// happened to be zero" are different facts.
const happened = (r: Ride) => r.status === "completed";
const STATUS_CLASS: Record<string, string> = {
completed: "paid",
cancelled: "unpaid",
expired: "unpaid",
};
export default function Rides() {
const [rides, setRides] = useState<Ride[]>([]);
const [meta, setMeta] = useState({ total: 0, page: 1, pages: 1 });
@@ -78,9 +103,18 @@ export default function Rides() {
setPage(1);
}}
>
<option value="">All payments</option>
<option value="paid">Paid</option>
<option value="unpaid">Unpaid</option>
{/* "Unpaid" used to be an option here, but no row ever carries that
value — payment_status is paid / cash / cash_collected — so the
filter silently returned nothing. These are the real values, plus
the ride's own lifecycle state, which is what an operator
actually wants to filter by. */}
<option value="">All rides</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
<option value="expired">No driver found</option>
<option value="paid">Paid by card</option>
<option value="cash">Cash owed</option>
<option value="cash_collected">Cash collected</option>
</select>
<button className="secondary" onClick={search}>
Search
@@ -105,7 +139,11 @@ export default function Rides() {
<th>Driver</th>
<th>Time (min)</th>
<th>Fare</th>
<th>Driver gets</th>
<th>Company gets</th>
<th>Ride</th>
<th>Payment</th>
<th>Settled</th>
<th>Date</th>
</tr>
</thead>
@@ -117,17 +155,75 @@ export default function Rides() {
{r.origin_address} {r.destination_address}
</td>
<td>{r.user_email}</td>
<td>{r.driver.name}</td>
<td>{r.driver?.name ?? <span className="muted">no driver</span>}</td>
<td>{r.ride_time}</td>
<td>{fmt(r.fare_price)}</td>
<td>{money(r.fare_price)}</td>
<td>{happened(r) ? money(r.driver_payout_cents) : "—"}</td>
<td>{happened(r) ? money(r.platform_fee_cents) : "—"}</td>
<td>
<span
className={`badge ${
r.payment_status.toLowerCase() === "paid" ? "paid" : "unpaid"
}`}
>
{r.payment_status}
<span className={`badge ${STATUS_CLASS[r.status] ?? ""}`}>
{r.status}
</span>
{r.cancellation_reason ? (
<div className="muted" style={{ fontSize: 11 }}>
{r.cancelled_by}: {r.cancellation_reason.replace(/_/g, " ")}
</div>
) : null}
</td>
<td>
{/* A ride that never happened has no payment to report as
pending — it owes nobody anything. */}
{happened(r) ? (
<span
className={`badge ${
["paid", "cash_collected"].includes(
r.payment_status.toLowerCase(),
)
? "paid"
: "unpaid"
}`}
>
{r.payment_status}
</span>
) : (
<span className="muted">not charged</span>
)}
</td>
<td>
{/* Two directions: cash rides leave the company waiting on
its fee, card rides leave the driver waiting on their
payout. A ride that produced no money owes nobody. */}
{!happened(r) ||
!["paid", "cash_collected"].includes(
r.payment_status.toLowerCase(),
) ? (
<span className="muted"></span>
) : (
<>
<div
className={
r.platform_fee_settled_at ? "" : "muted"
}
style={{ fontSize: 11 }}
title="Company's commission"
>
{r.platform_fee_settled_at
? "company paid"
: "company awaiting"}
</div>
<div
className={
r.driver_payout_settled_at ? "" : "muted"
}
style={{ fontSize: 11 }}
title="Driver's payout"
>
{r.driver_payout_settled_at
? "driver paid"
: "driver awaiting"}
</div>
</>
)}
</td>
<td>{new Date(r.created_at).toLocaleString()}</td>
</tr>
+58 -15
View File
@@ -6,15 +6,28 @@ type Stats = {
users: number;
drivers: number;
rides: number;
revenue: number;
completed_rides: number;
cancelled_rides: number;
gross_fares: number;
driver_payouts: number;
company_revenue: number;
company_collected: number;
company_outstanding: number;
driver_outstanding: number;
rides_today: number;
avg_fare: number;
pending_count: number;
pending_revenue: number;
new_users_7d: number;
};
trend: { day: string; rides: number; revenue: number }[];
topDrivers: { driver_id: number; name: string; rides: number; revenue: number }[];
trend: { day: string; rides: number; revenue: number; payouts: number }[];
topDrivers: {
driver_id: number;
name: string;
rides: number;
earnings: number;
company_revenue: number;
}[];
};
const fmt = (n: number) => n.toLocaleString();
@@ -22,7 +35,7 @@ const fmt = (n: number) => n.toLocaleString();
export default function Stats() {
const [stats, setStats] = useState<Stats | null>(null);
const [error, setError] = useState<string | null>(null);
const [metric, setMetric] = useState<"rides" | "revenue">("rides");
const [metric, setMetric] = useState<"rides" | "revenue" | "payouts">("rides");
useEffect(() => {
api<{ data: Stats }>("/admin/stats")
@@ -52,25 +65,51 @@ export default function Stats() {
<div className="card">
<div className="label">Rides</div>
<div className="value">{fmt(t.rides)}</div>
<div className="sub">{fmt(t.rides_today)} today</div>
<div className="sub">
{fmt(t.completed_rides)} completed · {fmt(t.cancelled_rides)} cancelled
</div>
</div>
<div className="card">
<div className="label">Revenue (paid)</div>
<div className="value">{fmt(t.revenue)}</div>
<div className="sub">avg fare {fmt(t.avg_fare)}</div>
<div className="label">Gross fares</div>
<div className="value">{fmt(t.gross_fares)}</div>
<div className="sub">what riders paid · avg {fmt(t.avg_fare)}</div>
</div>
<div className="card">
<div className="label">Company revenue</div>
<div className="value">{fmt(t.company_revenue)}</div>
<div className="sub">
{fmt(t.company_collected)} collected
</div>
</div>
{/* Commission drivers took in cash and haven't handed over yet. This
is the number to chase at the end of a shift. */}
<div className={`card ${t.company_outstanding > 0 ? "warn" : ""}`}>
<div className="label">Commission to collect</div>
<div className="value">{fmt(t.company_outstanding)}</div>
<div className="sub">held by drivers from cash rides</div>
</div>
<div className="card">
<div className="label">Payouts owed</div>
<div className="value">{fmt(t.driver_outstanding)}</div>
<div className="sub">of {fmt(t.driver_payouts)} total earned</div>
</div>
{/* Completed rides whose money never landed. Cancelled rides are no
longer counted here — they never owed anything. */}
<div className={`card ${t.pending_count > 0 ? "warn" : ""}`}>
<div className="label">Pending payments</div>
<div className="label">Uncollected</div>
<div className="value">{fmt(t.pending_count)}</div>
<div className="sub">{fmt(t.pending_revenue)} outstanding</div>
<div className="sub">{fmt(t.pending_revenue)} on completed rides</div>
</div>
</div>
<h2>Last 14 days</h2>
<div className="toolbar">
<select value={metric} onChange={(e) => setMetric(e.target.value as "rides" | "revenue")}>
<select value={metric} onChange={(e) =>
setMetric(e.target.value as "rides" | "revenue" | "payouts")
}>
<option value="rides">Rides</option>
<option value="revenue">Revenue (paid)</option>
<option value="revenue">Company revenue</option>
<option value="payouts">Driver payouts</option>
</select>
</div>
<div className="chart" style={{ marginBottom: 40 }}>
@@ -79,7 +118,9 @@ export default function Stats() {
key={d.day}
className="bar"
style={{ height: `${Math.max(1, (d[metric] / max) * 100)}%` }}
title={`${d.day}: ${metric === "rides" ? `${d.rides} rides` : fmt(d.revenue)}`}
title={`${d.day}: ${
metric === "rides" ? `${d.rides} rides` : fmt(d[metric])
}`}
>
<span>{d.day.slice(5)}</span>
</div>
@@ -92,7 +133,8 @@ export default function Stats() {
<tr>
<th>Driver</th>
<th>Rides</th>
<th>Revenue</th>
<th>Driver earned</th>
<th>Company earned</th>
</tr>
</thead>
<tbody>
@@ -100,7 +142,8 @@ export default function Stats() {
<tr key={d.driver_id}>
<td>{d.name}</td>
<td>{fmt(d.rides)}</td>
<td>{fmt(d.revenue)}</td>
<td>{fmt(d.earnings)}</td>
<td>{fmt(d.company_revenue)}</td>
</tr>
))}
</tbody>