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>
780 lines
23 KiB
TypeScript
780 lines
23 KiB
TypeScript
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
|
import { api, apiObjectUrl } from "../lib/api";
|
|
|
|
type Driver = {
|
|
id: number;
|
|
first_name: string;
|
|
last_name: string;
|
|
profile_image_url: string | null;
|
|
car_image_url: string | null;
|
|
car_seats: number;
|
|
rating: string;
|
|
service: string;
|
|
online: boolean;
|
|
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: "",
|
|
profile_image_url: "",
|
|
car_image_url: "",
|
|
car_seats: 4,
|
|
rating: 4.5,
|
|
};
|
|
|
|
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, 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);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
const remove = async (id: number) => {
|
|
if (!confirm("Delete this driver?")) return;
|
|
try {
|
|
await api(`/admin/drivers/${id}`, { method: "DELETE" });
|
|
await load();
|
|
} catch (e) {
|
|
setError((e as Error).message);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<h2>Drivers & fleet</h2>
|
|
<div className="toolbar">
|
|
<button onClick={() => setEditing("new")}>Add driver</button>
|
|
</div>
|
|
{error && <div className="error">{error}</div>}
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Name</th>
|
|
<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>
|
|
<tbody>
|
|
{drivers.map((d) => (
|
|
<tr key={d.id}>
|
|
<td>{d.id}</td>
|
|
<td>
|
|
{d.first_name} {d.last_name}
|
|
</td>
|
|
<td>
|
|
<span className={`tag tag-${d.service}`}>{d.service}</span>
|
|
</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>
|
|
<button className="danger" onClick={() => remove(d.id)}>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</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}
|
|
onClose={() => setEditing(null)}
|
|
onSaved={() => {
|
|
setEditing(null);
|
|
load();
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// 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,
|
|
onSaved,
|
|
}: {
|
|
initial: Driver | null;
|
|
onClose: () => void;
|
|
onSaved: () => void;
|
|
}) {
|
|
const [form, setForm] = useState(
|
|
initial
|
|
? {
|
|
first_name: initial.first_name,
|
|
last_name: initial.last_name,
|
|
profile_image_url: initial.profile_image_url ?? "",
|
|
car_image_url: initial.car_image_url ?? "",
|
|
car_seats: initial.car_seats,
|
|
rating: Number(initial.rating),
|
|
}
|
|
: { ...EMPTY },
|
|
);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const set = (key: keyof typeof form) => (e: { target: { value: string } }) =>
|
|
setForm((f) => ({
|
|
...f,
|
|
[key]:
|
|
key === "car_seats" || key === "rating"
|
|
? Number(e.target.value)
|
|
: e.target.value,
|
|
}));
|
|
|
|
const submit = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
setBusy(true);
|
|
try {
|
|
await api(initial ? `/admin/drivers/${initial.id}` : "/admin/drivers", {
|
|
method: initial ? "PATCH" : "POST",
|
|
body: JSON.stringify(form),
|
|
});
|
|
onSaved();
|
|
} catch (err) {
|
|
setError((err as Error).message);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="modal-backdrop" onClick={onClose}>
|
|
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
|
<h3>{initial ? `Edit driver #${initial.id}` : "New driver"}</h3>
|
|
<input placeholder="First name" value={form.first_name} onChange={set("first_name")} required />
|
|
<input placeholder="Last name" value={form.last_name} onChange={set("last_name")} required />
|
|
<input placeholder="Profile image URL" value={form.profile_image_url} onChange={set("profile_image_url")} />
|
|
<input placeholder="Car image URL" value={form.car_image_url} onChange={set("car_image_url")} />
|
|
<label>
|
|
Seats{" "}
|
|
<select value={form.car_seats} onChange={set("car_seats")}>
|
|
{[2, 4, 6, 7].map((n) => (
|
|
<option key={n} value={n}>
|
|
{n}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="0.1"
|
|
min="1"
|
|
max="5"
|
|
placeholder="Rating"
|
|
value={form.rating}
|
|
onChange={set("rating")}
|
|
/>
|
|
{error && <div className="error">{error}</div>}
|
|
<div className="row-actions">
|
|
<button disabled={busy}>{busy ? "Saving…" : "Save"}</button>
|
|
<button type="button" className="secondary" onClick={onClose}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</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>
|
|
);
|
|
}
|