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:
co-authored by
Claude Opus 5
parent
1d84003e0a
commit
8807ff41c5
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user