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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user